33 */
44
55import React , { useState , useMemo } from "react" ;
6- import type { DiffHunk } from "@/types/review" ;
6+ import type { DiffHunk , HunkReadMoreState } from "@/types/review" ;
77import { SelectableDiffRenderer } from "../../shared/DiffRenderer" ;
88import {
99 type SearchHighlightConfig ,
1010 highlightSearchInText ,
1111} from "@/utils/highlighting/highlightSearchTerms" ;
1212import { Tooltip , TooltipWrapper } from "../../Tooltip" ;
1313import { usePersistedState } from "@/hooks/usePersistedState" ;
14- import { getReviewExpandStateKey } from "@/constants/storage" ;
14+ import { getReviewExpandStateKey , getReviewReadMoreStateKey } from "@/constants/storage" ;
1515import { KEYBINDS , formatKeybind } from "@/utils/ui/keybinds" ;
1616import { cn } from "@/lib/utils" ;
17+ import {
18+ readFileLines ,
19+ calculateUpwardExpansion ,
20+ calculateDownwardExpansion ,
21+ formatAsContextLines ,
22+ } from "@/utils/review/readFileLines" ;
1723
1824interface HunkViewerProps {
1925 hunk : DiffHunk ;
@@ -105,6 +111,61 @@ export const HunkViewer = React.memo<HunkViewerProps>(
105111 }
106112 } , [ hasManualState , manualExpandState ] ) ;
107113
114+ // Read-more state: tracks expanded lines up/down per hunk
115+ const [ readMoreStateMap , setReadMoreStateMap ] = usePersistedState <
116+ Record < string , HunkReadMoreState >
117+ > ( getReviewReadMoreStateKey ( workspaceId ) , { } , { listener : true } ) ;
118+
119+ const readMoreState = useMemo (
120+ ( ) => readMoreStateMap [ hunkId ] || { up : 0 , down : 0 } ,
121+ [ readMoreStateMap , hunkId ]
122+ ) ;
123+
124+ // State for expanded content
125+ const [ expandedContentUp , setExpandedContentUp ] = useState < string > ( "" ) ;
126+ const [ expandedContentDown , setExpandedContentDown ] = useState < string > ( "" ) ;
127+ const [ isLoadingUp , setIsLoadingUp ] = useState ( false ) ;
128+ const [ isLoadingDown , setIsLoadingDown ] = useState ( false ) ;
129+
130+ // Load expanded content when read-more state changes
131+ React . useEffect ( ( ) => {
132+ if ( readMoreState . up > 0 ) {
133+ const expansion = calculateUpwardExpansion ( hunk . oldStart , readMoreState . up ) ;
134+ if ( expansion . numLines > 0 ) {
135+ setIsLoadingUp ( true ) ;
136+ void readFileLines ( workspaceId , hunk . filePath , expansion . startLine , expansion . endLine )
137+ . then ( ( lines ) => {
138+ if ( lines ) {
139+ setExpandedContentUp ( formatAsContextLines ( lines ) ) ;
140+ }
141+ } )
142+ . finally ( ( ) => setIsLoadingUp ( false ) ) ;
143+ }
144+ } else {
145+ setExpandedContentUp ( "" ) ;
146+ }
147+ } , [ readMoreState . up , hunk . oldStart , hunk . filePath , workspaceId ] ) ;
148+
149+ React . useEffect ( ( ) => {
150+ if ( readMoreState . down > 0 ) {
151+ const expansion = calculateDownwardExpansion (
152+ hunk . oldStart ,
153+ hunk . oldLines ,
154+ readMoreState . down
155+ ) ;
156+ setIsLoadingDown ( true ) ;
157+ void readFileLines ( workspaceId , hunk . filePath , expansion . startLine , expansion . endLine )
158+ . then ( ( lines ) => {
159+ if ( lines ) {
160+ setExpandedContentDown ( formatAsContextLines ( lines ) ) ;
161+ }
162+ } )
163+ . finally ( ( ) => setIsLoadingDown ( false ) ) ;
164+ } else {
165+ setExpandedContentDown ( "" ) ;
166+ }
167+ } , [ readMoreState . down , hunk . oldStart , hunk . oldLines , hunk . filePath , workspaceId ] ) ;
168+
108169 const handleToggleExpand = React . useCallback (
109170 ( e ?: React . MouseEvent ) => {
110171 e ?. stopPropagation ( ) ;
@@ -131,6 +192,39 @@ export const HunkViewer = React.memo<HunkViewerProps>(
131192 onToggleRead ?.( e ) ;
132193 } ;
133194
195+ const handleExpandUp = React . useCallback (
196+ ( e : React . MouseEvent ) => {
197+ e . stopPropagation ( ) ;
198+ const expansion = calculateUpwardExpansion ( hunk . oldStart , readMoreState . up ) ;
199+ if ( expansion . startLine < 1 || expansion . numLines <= 0 ) {
200+ // Already at beginning of file
201+ return ;
202+ }
203+ setReadMoreStateMap ( ( prev ) => ( {
204+ ...prev ,
205+ [ hunkId ] : {
206+ ...readMoreState ,
207+ up : readMoreState . up + 30 ,
208+ } ,
209+ } ) ) ;
210+ } ,
211+ [ hunkId , hunk . oldStart , readMoreState , setReadMoreStateMap ]
212+ ) ;
213+
214+ const handleExpandDown = React . useCallback (
215+ ( e : React . MouseEvent ) => {
216+ e . stopPropagation ( ) ;
217+ setReadMoreStateMap ( ( prev ) => ( {
218+ ...prev ,
219+ [ hunkId ] : {
220+ ...readMoreState ,
221+ down : readMoreState . down + 30 ,
222+ } ,
223+ } ) ) ;
224+ } ,
225+ [ hunkId , readMoreState , setReadMoreStateMap ]
226+ ) ;
227+
134228 // Detect pure rename: if renamed and content hasn't changed (zero additions and deletions)
135229 const isPureRename =
136230 hunk . changeType === "renamed" && hunk . oldPath && additions === 0 && deletions === 0 ;
@@ -199,23 +293,86 @@ export const HunkViewer = React.memo<HunkViewerProps>(
199293 Renamed from < code > { hunk . oldPath } </ code >
200294 </ div >
201295 ) : isExpanded ? (
202- < div className = "font-monospace bg-code-bg grid grid-cols-[minmax(min-content,1fr)] overflow-x-auto px-2 py-1.5 text-[11px] leading-[1.4]" >
203- < SelectableDiffRenderer
204- content = { hunk . content }
205- filePath = { hunk . filePath }
206- oldStart = { hunk . oldStart }
207- newStart = { hunk . newStart }
208- maxHeight = "none"
209- onReviewNote = { onReviewNote }
210- onLineClick = { ( ) => {
211- // Create synthetic event with data-hunk-id for parent handler
212- const syntheticEvent = {
213- currentTarget : { dataset : { hunkId } } ,
214- } as unknown as React . MouseEvent < HTMLElement > ;
215- onClick ?.( syntheticEvent ) ;
216- } }
217- searchConfig = { searchConfig }
218- />
296+ < div className = "font-monospace bg-code-bg grid grid-cols-[minmax(min-content,1fr)] overflow-x-auto text-[11px] leading-[1.4]" >
297+ { /* Read more upward button */ }
298+ { ( ( ) => {
299+ const expansion = calculateUpwardExpansion ( hunk . oldStart , readMoreState . up ) ;
300+ const canExpandUp = expansion . startLine >= 1 && expansion . numLines > 0 ;
301+ return (
302+ canExpandUp && (
303+ < div className = "border-border-light border-b px-2 py-1.5" >
304+ < button
305+ onClick = { handleExpandUp }
306+ disabled = { isLoadingUp }
307+ className = "text-muted hover:text-foreground disabled:text-muted w-full text-center text-[11px] italic disabled:cursor-not-allowed"
308+ >
309+ { isLoadingUp ? "Loading..." : `Read ${ expansion . numLines } more lines ↑` }
310+ </ button >
311+ </ div >
312+ )
313+ ) ;
314+ } ) ( ) }
315+ { /* Expanded content upward */ }
316+ { expandedContentUp && (
317+ < div className = "px-2 py-1.5" >
318+ < SelectableDiffRenderer
319+ content = { expandedContentUp }
320+ filePath = { hunk . filePath }
321+ oldStart = { calculateUpwardExpansion ( hunk . oldStart , readMoreState . up ) . startLine }
322+ newStart = { calculateUpwardExpansion ( hunk . oldStart , readMoreState . up ) . startLine }
323+ maxHeight = "none"
324+ searchConfig = { searchConfig }
325+ />
326+ </ div >
327+ ) }
328+ { /* Original hunk content */ }
329+ < div className = "px-2 py-1.5" >
330+ < SelectableDiffRenderer
331+ content = { hunk . content }
332+ filePath = { hunk . filePath }
333+ oldStart = { hunk . oldStart }
334+ newStart = { hunk . newStart }
335+ maxHeight = "none"
336+ onReviewNote = { onReviewNote }
337+ onLineClick = { ( ) => {
338+ // Create synthetic event with data-hunk-id for parent handler
339+ const syntheticEvent = {
340+ currentTarget : { dataset : { hunkId } } ,
341+ } as unknown as React . MouseEvent < HTMLElement > ;
342+ onClick ?.( syntheticEvent ) ;
343+ } }
344+ searchConfig = { searchConfig }
345+ />
346+ </ div >
347+ { /* Expanded content downward */ }
348+ { expandedContentDown && (
349+ < div className = "px-2 py-1.5" >
350+ < SelectableDiffRenderer
351+ content = { expandedContentDown }
352+ filePath = { hunk . filePath }
353+ oldStart = {
354+ calculateDownwardExpansion ( hunk . oldStart , hunk . oldLines , readMoreState . down )
355+ . startLine
356+ }
357+ newStart = {
358+ calculateDownwardExpansion ( hunk . oldStart , hunk . oldLines , readMoreState . down )
359+ . startLine
360+ }
361+ maxHeight = "none"
362+ searchConfig = { searchConfig }
363+ />
364+ </ div >
365+ ) }
366+ { /* Read more downward button */ }
367+ < div className = "border-border-light border-t px-2 py-1.5" >
368+ < button
369+ onClick = { handleExpandDown }
370+ disabled = { isLoadingDown }
371+ className = "text-muted hover:text-foreground disabled:text-muted w-full text-center text-[11px] italic disabled:cursor-not-allowed"
372+ >
373+ { isLoadingDown ? "Loading..." : "Read 30 more lines ↓" }
374+ </ button >
375+ </ div >
219376 </ div >
220377 ) : (
221378 < div
0 commit comments