@@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
1313import { useI18n } from "../context/i18n"
1414import { getDirectory , getFilename } from "@opencode-ai/util/path"
1515import { checksum } from "@opencode-ai/util/encode"
16- import { createEffect , createMemo , For , Match , Show , Switch , untrack , type JSX } from "solid-js"
17- import { onCleanup } from "solid-js"
16+ import { createEffect , createMemo , For , Match , onCleanup , Show , Switch , untrack , type JSX } from "solid-js"
1817import { createStore } from "solid-js/store"
1918import { type FileContent , type FileDiff } from "@opencode-ai/sdk/v2"
2019import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -26,6 +25,7 @@ import { createLineCommentController } from "./line-comment-annotations"
2625import type { LineCommentEditorProps } from "./line-comment"
2726
2827const MAX_DIFF_CHANGED_LINES = 500
28+ const REVIEW_MOUNT_MARGIN = 300
2929
3030export type SessionReviewDiffStyle = "unified" | "split"
3131
@@ -69,7 +69,7 @@ export interface SessionReviewProps {
6969 split ?: boolean
7070 diffStyle ?: SessionReviewDiffStyle
7171 onDiffStyleChange ?: ( diffStyle : SessionReviewDiffStyle ) => void
72- onDiffRendered ?: ( ) => void
72+ onDiffRendered ?: VoidFunction
7373 onLineComment ?: ( comment : SessionReviewLineComment ) => void
7474 onLineCommentUpdate ?: ( comment : SessionReviewCommentUpdate ) => void
7575 onLineCommentDelete ?: ( comment : SessionReviewCommentDelete ) => void
@@ -137,11 +137,14 @@ type SessionReviewSelection = {
137137export const SessionReview = ( props : SessionReviewProps ) => {
138138 let scroll : HTMLDivElement | undefined
139139 let focusToken = 0
140+ let frame : number | undefined
140141 const i18n = useI18n ( )
141142 const fileComponent = useFileComponent ( )
142143 const anchors = new Map < string , HTMLElement > ( )
144+ const nodes = new Map < string , HTMLDivElement > ( )
143145 const [ store , setStore ] = createStore ( {
144146 open : [ ] as string [ ] ,
147+ visible : { } as Record < string , boolean > ,
145148 force : { } as Record < string , boolean > ,
146149 selection : null as SessionReviewSelection | null ,
147150 commenting : null as SessionReviewSelection | null ,
@@ -154,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => {
154157 const open = ( ) => props . open ?? store . open
155158 const files = createMemo ( ( ) => props . diffs . map ( ( diff ) => diff . file ) )
156159 const diffs = createMemo ( ( ) => new Map ( props . diffs . map ( ( diff ) => [ diff . file , diff ] as const ) ) )
160+ const grouped = createMemo ( ( ) => {
161+ const next = new Map < string , SessionReviewComment [ ] > ( )
162+ for ( const comment of props . comments ?? [ ] ) {
163+ const list = next . get ( comment . file )
164+ if ( list ) {
165+ list . push ( comment )
166+ continue
167+ }
168+ next . set ( comment . file , [ comment ] )
169+ }
170+ return next
171+ } )
157172 const diffStyle = ( ) => props . diffStyle ?? ( props . split ? "split" : "unified" )
158173 const hasDiffs = ( ) => files ( ) . length > 0
159174
160- const handleChange = ( open : string [ ] ) => {
161- props . onOpenChange ?.( open )
162- if ( props . open !== undefined ) return
163- setStore ( "open" , open )
175+ const syncVisible = ( ) => {
176+ frame = undefined
177+ if ( ! scroll ) return
178+
179+ const root = scroll . getBoundingClientRect ( )
180+ const top = root . top - REVIEW_MOUNT_MARGIN
181+ const bottom = root . bottom + REVIEW_MOUNT_MARGIN
182+ const openSet = new Set ( open ( ) )
183+ const next : Record < string , boolean > = { }
184+
185+ for ( const [ file , el ] of nodes ) {
186+ if ( ! openSet . has ( file ) ) continue
187+ const rect = el . getBoundingClientRect ( )
188+ if ( rect . bottom < top || rect . top > bottom ) continue
189+ next [ file ] = true
190+ }
191+
192+ const prev = untrack ( ( ) => store . visible )
193+ const prevKeys = Object . keys ( prev )
194+ const nextKeys = Object . keys ( next )
195+ if ( prevKeys . length === nextKeys . length && nextKeys . every ( ( file ) => prev [ file ] ) ) return
196+ setStore ( "visible" , next )
197+ }
198+
199+ const queue = ( ) => {
200+ if ( frame !== undefined ) return
201+ frame = requestAnimationFrame ( syncVisible )
202+ }
203+
204+ const pinned = ( file : string ) =>
205+ props . focusedComment ?. file === file ||
206+ props . focusedFile === file ||
207+ selection ( ) ?. file === file ||
208+ commenting ( ) ?. file === file ||
209+ opened ( ) ?. file === file
210+
211+ const handleScroll : JSX . EventHandler < HTMLDivElement , Event > = ( event ) => {
212+ queue ( )
213+ const next = props . onScroll
214+ if ( ! next ) return
215+ if ( Array . isArray ( next ) ) {
216+ const [ fn , data ] = next as [ ( data : unknown , event : Event ) => void , unknown ]
217+ fn ( data , event )
218+ return
219+ }
220+ ; ( next as JSX . EventHandler < HTMLDivElement , Event > ) ( event )
221+ }
222+
223+ onCleanup ( ( ) => {
224+ if ( frame === undefined ) return
225+ cancelAnimationFrame ( frame )
226+ } )
227+
228+ createEffect ( ( ) => {
229+ props . open
230+ files ( )
231+ queue ( )
232+ } )
233+
234+ const handleChange = ( next : string [ ] ) => {
235+ props . onOpenChange ?.( next )
236+ if ( props . open === undefined ) setStore ( "open" , next )
237+ queue ( )
164238 }
165239
166240 const handleExpandOrCollapseAll = ( ) => {
@@ -274,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
274348 viewportRef = { ( el ) => {
275349 scroll = el
276350 props . scrollRef ?.( el )
351+ queue ( )
277352 } }
278- onScroll = { props . onScroll as any }
353+ onScroll = { handleScroll }
279354 classList = { {
280355 [ props . classes ?. root ?? "" ] : ! ! props . classes ?. root ,
281356 } }
@@ -291,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
291366 const item = createMemo ( ( ) => diffs ( ) . get ( file ) ! )
292367
293368 const expanded = createMemo ( ( ) => open ( ) . includes ( file ) )
369+ const mounted = createMemo ( ( ) => expanded ( ) && ( ! ! store . visible [ file ] || pinned ( file ) ) )
294370 const force = ( ) => ! ! store . force [ file ]
295371
296- const comments = createMemo ( ( ) => ( props . comments ?? [ ] ) . filter ( ( c ) => c . file === file ) )
372+ const comments = createMemo ( ( ) => grouped ( ) . get ( file ) ?? [ ] )
297373 const commentedLines = createMemo ( ( ) => comments ( ) . map ( ( c ) => c . selection ) )
298374
299375 const beforeText = ( ) => ( typeof item ( ) . before === "string" ? item ( ) . before : "" )
@@ -381,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
381457
382458 onCleanup ( ( ) => {
383459 anchors . delete ( file )
460+ nodes . delete ( file )
461+ queue ( )
384462 } )
385463
386464 const handleLineSelected = ( range : SelectedLineRange | null ) => {
@@ -465,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
465543 ref = { ( el ) => {
466544 wrapper = el
467545 anchors . set ( file , el )
546+ nodes . set ( file , el )
547+ queue ( )
468548 } }
469549 >
470550 < Show when = { expanded ( ) } >
471551 < Switch >
552+ < Match when = { ! mounted ( ) && ! tooLarge ( ) } >
553+ < div
554+ data-slot = "session-review-diff-placeholder"
555+ class = "rounded-lg border border-border-weak-base bg-background-stronger/40"
556+ style = { { height : "160px" } }
557+ />
558+ </ Match >
472559 < Match when = { tooLarge ( ) } >
473560 < div data-slot = "session-review-large-diff" >
474561 < div data-slot = "session-review-large-diff-title" >
0 commit comments