11'use client'
22
3- import { useMemo , useState , useCallback } from 'react'
3+ import { useMemo , useState , useCallback , useEffect , useRef } from 'react'
44import { Icon } from '@iconify/react'
55
66interface DiffViewerProps {
@@ -104,6 +104,9 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
104104 const [ acceptedHunks , setAcceptedHunks ] = useState < Set < number > > ( new Set ( ) )
105105 const [ rejectedHunks , setRejectedHunks ] = useState < Set < number > > ( new Set ( ) )
106106 const [ flashApply , setFlashApply ] = useState ( false )
107+ const [ currentHunkIndex , setCurrentHunkIndex ] = useState ( 0 )
108+ const containerRef = useRef < HTMLDivElement > ( null )
109+ const hunkRefs = useRef < Map < number , HTMLDivElement > > ( new Map ( ) )
107110
108111 const handleAcceptHunk = useCallback ( ( hunkIdx : number ) => {
109112 setAcceptedHunks ( ( prev ) => {
@@ -138,6 +141,107 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
138141 } , 400 )
139142 } , [ onApply ] )
140143
144+ const handleKeepAll = useCallback ( ( ) => {
145+ setAcceptedHunks ( new Set ( hunks . map ( ( _ , idx ) => idx ) ) )
146+ setRejectedHunks ( new Set ( ) )
147+ } , [ hunks ] )
148+
149+ const handleUndoAll = useCallback ( ( ) => {
150+ setAcceptedHunks ( new Set ( ) )
151+ setRejectedHunks ( new Set ( hunks . map ( ( _ , idx ) => idx ) ) )
152+ } , [ hunks ] )
153+
154+ const navigateHunk = useCallback (
155+ ( direction : 'prev' | 'next' ) => {
156+ if ( hunks . length === 0 ) return
157+ const newIndex =
158+ direction === 'prev'
159+ ? currentHunkIndex > 0
160+ ? currentHunkIndex - 1
161+ : hunks . length - 1
162+ : currentHunkIndex < hunks . length - 1
163+ ? currentHunkIndex + 1
164+ : 0
165+ setCurrentHunkIndex ( newIndex )
166+
167+ // Scroll to hunk
168+ const hunkElement = hunkRefs . current . get ( newIndex )
169+ if ( hunkElement ) {
170+ hunkElement . scrollIntoView ( { behavior : 'smooth' , block : 'center' } )
171+ }
172+ } ,
173+ [ currentHunkIndex , hunks . length ]
174+ )
175+
176+ const handleCurrentHunkAction = useCallback (
177+ ( action : 'accept' | 'reject' ) => {
178+ if ( hunks . length === 0 ) return
179+ if ( action === 'accept' ) {
180+ handleAcceptHunk ( currentHunkIndex )
181+ } else {
182+ handleRejectHunk ( currentHunkIndex )
183+ }
184+ } ,
185+ [ currentHunkIndex , hunks . length , handleAcceptHunk , handleRejectHunk ]
186+ )
187+
188+ // Keyboard shortcuts
189+ useEffect ( ( ) => {
190+ const handleKeyDown = ( e : KeyboardEvent ) => {
191+ const isMac = navigator . platform . toUpperCase ( ) . indexOf ( 'MAC' ) >= 0
192+ const modKey = isMac ? e . metaKey : e . ctrlKey
193+
194+ // ⌘Y / Ctrl+Y - Keep current hunk
195+ if ( modKey && e . key . toLowerCase ( ) === 'y' ) {
196+ e . preventDefault ( )
197+ handleCurrentHunkAction ( 'accept' )
198+ }
199+ // ⌘N / Ctrl+N - Undo current hunk
200+ else if ( modKey && e . key . toLowerCase ( ) === 'n' ) {
201+ e . preventDefault ( )
202+ handleCurrentHunkAction ( 'reject' )
203+ }
204+ // ⌘⏎ / Ctrl+Enter - Keep All
205+ else if ( modKey && e . key === 'Enter' ) {
206+ e . preventDefault ( )
207+ handleKeepAll ( )
208+ }
209+ // ⌘⌫ / Ctrl+Backspace - Undo All
210+ else if ( modKey && e . key === 'Backspace' ) {
211+ e . preventDefault ( )
212+ handleUndoAll ( )
213+ }
214+ // ↑/↓ or J/K - Navigate hunks
215+ else if ( e . key === 'ArrowUp' || e . key . toLowerCase ( ) === 'k' ) {
216+ e . preventDefault ( )
217+ navigateHunk ( 'prev' )
218+ } else if ( e . key === 'ArrowDown' || e . key . toLowerCase ( ) === 'j' ) {
219+ e . preventDefault ( )
220+ navigateHunk ( 'next' )
221+ }
222+ }
223+
224+ window . addEventListener ( 'keydown' , handleKeyDown )
225+ return ( ) => window . removeEventListener ( 'keydown' , handleKeyDown )
226+ } , [ handleCurrentHunkAction , handleKeepAll , handleUndoAll , navigateHunk ] )
227+
228+ // Auto-scroll to first hunk on mount
229+ useEffect ( ( ) => {
230+ if ( hunks . length > 0 ) {
231+ const firstHunk = hunkRefs . current . get ( 0 )
232+ if ( firstHunk ) {
233+ setTimeout ( ( ) => {
234+ firstHunk . scrollIntoView ( { behavior : 'smooth' , block : 'center' } )
235+ } , 100 )
236+ }
237+ }
238+ } , [ hunks . length ] )
239+
240+ // Focus container on mount
241+ useEffect ( ( ) => {
242+ containerRef . current ?. focus ( )
243+ } , [ ] )
244+
141245 // Determine which hunk a line belongs to (if any)
142246 const lineHunkMap = useMemo ( ( ) => {
143247 const map = new Map < number , number > ( )
@@ -151,20 +255,22 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
151255
152256 return (
153257 < div
154- className = { `flex flex-col h-full border-t border-[var(--border)] bg-[var(--bg)] ${ flashApply ? 'diff-apply-flash' : '' } ` }
258+ ref = { containerRef }
259+ tabIndex = { 0 }
260+ className = { `flex flex-col h-full border-t border-[var(--border)] bg-[var(--bg)] outline-none ${ flashApply ? 'diff-apply-flash' : '' } ` }
155261 >
156262 { /* Header */ }
157263 < div className = "flex items-center justify-between px-3 py-2 border-b border-[var(--border)] bg-[var(--bg-elevated)] shrink-0" >
158264 < div className = "flex items-center gap-2 min-w-0" >
159- < Icon icon = "lucide:git-compare" width = { 14 } height = { 14 } className = "text-[var(--brand)]" />
160- < span className = "text-[11px ] font-semibold text-[var(--text-primary)] truncate" >
265+ < Icon icon = "lucide:git-compare" width = { 17 } height = { 17 } className = "text-[var(--brand)]" />
266+ < span className = "text-[13px ] font-semibold text-[var(--text-primary)] truncate" >
161267 Agent proposed changes
162268 </ span >
163- < span className = "text-[10px ] text-[var(--text-tertiary)] font-mono truncate" >
269+ < span className = "text-[12px ] text-[var(--text-tertiary)] font-mono truncate" >
164270 { filePath }
165271 </ span >
166272 { /* Changes summary badge */ }
167- < span className = "inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px ] font-medium bg-[var(--bg-subtle)] border border-[var(--border)]" >
273+ < span className = "inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px ] font-medium bg-[var(--bg-subtle)] border border-[var(--border)]" >
168274 < span className = "text-[var(--color-additions)]" > +{ additions } </ span >
169275 < span className = "text-[var(--color-deletions)]" > -{ deletions } </ span >
170276 < span className = "text-[var(--text-disabled)]" >
@@ -175,14 +281,14 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
175281 < div className = "flex items-center gap-1.5 shrink-0" >
176282 < button
177283 onClick = { onReject }
178- className = "flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px ] font-medium border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-colors cursor-pointer"
284+ className = "flex items-center gap-1 px-2.5 py-1 rounded-md text-[13px ] font-medium border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-colors cursor-pointer"
179285 >
180- < Icon icon = "lucide:x" width = { 12 } height = { 12 } />
286+ < Icon icon = "lucide:x" width = { 14 } height = { 14 } />
181287 Reject
182288 </ button >
183289 < button
184290 onClick = { handleApplyAll }
185- className = "flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px ] font-medium transition-colors cursor-pointer"
291+ className = "flex items-center gap-1 px-2.5 py-1 rounded-md text-[13px ] font-medium transition-colors cursor-pointer"
186292 style = { {
187293 backgroundColor : 'color-mix(in srgb, var(--color-additions) 15%, transparent)' ,
188294 borderColor : 'color-mix(in srgb, var(--color-additions) 30%, transparent)' ,
@@ -191,14 +297,14 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
191297 borderStyle : 'solid' ,
192298 } }
193299 >
194- < Icon icon = "lucide:check" width = { 12 } height = { 12 } />
300+ < Icon icon = "lucide:check" width = { 14 } height = { 14 } />
195301 Apply
196302 </ button >
197303 </ div >
198304 </ div >
199305
200306 { /* Diff lines */ }
201- < div className = "flex-1 overflow-auto font-mono text-[12px ] leading-[20px ]" >
307+ < div className = "flex-1 overflow-auto font-mono text-[14px ] leading-[24px ]" >
202308 { diff . map ( ( line , lineIdx ) => {
203309 const hunkIdx = lineHunkMap . get ( lineIdx )
204310 const isHunkStart = hunkIdx !== undefined && hunks [ hunkIdx ] ?. startIdx === lineIdx
@@ -209,32 +315,56 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
209315 < div key = { lineIdx } >
210316 { /* Hunk separator with per-hunk actions */ }
211317 { isHunkStart && hunkIdx !== undefined && (
212- < div className = "hunk-separator flex items-center justify-between px-3 py-1" >
213- < span className = "text-[9px] text-[var(--text-disabled)] font-mono" >
318+ < div
319+ ref = { ( el ) => {
320+ if ( el ) hunkRefs . current . set ( hunkIdx , el )
321+ } }
322+ className = { `hunk-separator flex items-center justify-between px-3 py-2 border-l-2 ${
323+ currentHunkIndex === hunkIdx
324+ ? 'border-l-[var(--brand)] bg-[color-mix(in_srgb,var(--brand)_5%,transparent)]'
325+ : 'border-l-transparent'
326+ } `}
327+ >
328+ < span className = "text-[11px] text-[var(--text-disabled)] font-mono" >
214329 Hunk { hunkIdx + 1 } : +{ hunks [ hunkIdx ] ! . additions } -{ hunks [ hunkIdx ] ! . deletions }
215330 </ span >
216- < div className = "flex items-center gap-1" >
331+ < div className = "flex items-center gap-1.5 " >
217332 { isHunkAccepted ? (
218- < span className = "text-[9px] text-[var(--color-additions)] font-medium flex items-center gap-0.5" >
219- < Icon icon = "lucide:check" width = { 10 } height = { 10 } /> Accepted
220- </ span >
333+ < button
334+ onClick = { ( ) => handleRejectHunk ( hunkIdx ) }
335+ className = "text-[11px] text-[var(--color-additions)] font-medium flex items-center gap-1 px-2 py-1 rounded-md hover:bg-[var(--bg-subtle)] transition-colors"
336+ >
337+ < Icon icon = "lucide:check" width = { 12 } height = { 12 } /> Accepted ✓
338+ </ button >
221339 ) : isHunkRejected ? (
222- < span className = "text-[9px] text-[var(--color-deletions)] font-medium flex items-center gap-0.5" >
223- < Icon icon = "lucide:x" width = { 10 } height = { 10 } /> Rejected
224- </ span >
340+ < button
341+ onClick = { ( ) => handleAcceptHunk ( hunkIdx ) }
342+ className = "text-[11px] text-[var(--color-deletions)] font-medium flex items-center gap-1 px-2 py-1 rounded-md hover:bg-[var(--bg-subtle)] transition-colors"
343+ >
344+ < Icon icon = "lucide:x" width = { 12 } height = { 12 } /> Rejected ✗
345+ </ button >
225346 ) : (
226347 < >
227348 < button
228- onClick = { ( ) => handleAcceptHunk ( hunkIdx ) }
229- className = "inline-diff-hunk-btn inline-diff-hunk-accept text-[9px ] font-medium"
349+ onClick = { ( ) => handleRejectHunk ( hunkIdx ) }
350+ className = "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px ] font-medium border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-colors "
230351 >
231- Accept
352+ Undo
353+ < span className = "text-[10px] text-[var(--text-disabled)]" > ⌘N</ span >
232354 </ button >
233355 < button
234- onClick = { ( ) => handleRejectHunk ( hunkIdx ) }
235- className = "inline-diff-hunk-btn inline-diff-hunk-reject text-[9px] font-medium"
356+ onClick = { ( ) => handleAcceptHunk ( hunkIdx ) }
357+ className = "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors"
358+ style = { {
359+ backgroundColor : 'color-mix(in srgb, var(--color-additions) 15%, transparent)' ,
360+ borderColor : 'color-mix(in srgb, var(--color-additions) 30%, transparent)' ,
361+ color : 'var(--color-additions)' ,
362+ borderWidth : '1px' ,
363+ borderStyle : 'solid' ,
364+ } }
236365 >
237- Reject
366+ Keep
367+ < span className = "text-[10px] opacity-70" > ⌘Y</ span >
238368 </ button >
239369 </ >
240370 ) }
@@ -244,7 +374,7 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
244374
245375 { /* Diff line */ }
246376 < div
247- className = { `flex ${
377+ className = { `flex relative ${
248378 isHunkRejected
249379 ? 'opacity-30'
250380 : line . type === 'added'
@@ -254,9 +384,19 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
254384 : ''
255385 } `}
256386 >
387+ { /* Gutter indicator */ }
388+ { line . type !== 'unchanged' && (
389+ < div
390+ className = "absolute left-0 top-0 bottom-0 w-[3px]"
391+ style = { {
392+ backgroundColor :
393+ line . type === 'added' ? 'var(--color-additions)' : 'var(--color-deletions)' ,
394+ } }
395+ />
396+ ) }
257397 { /* Old line number */ }
258398 < span
259- className = "w-10 shrink-0 text-right pr-2 select-none text-[var(--text-tertiary)]"
399+ className = "w-10 shrink-0 text-right pr-2 select-none text-[var(--text-tertiary)] pl-[3px] "
260400 style = { { opacity : line . type === 'added' ? 0.3 : 1 } }
261401 >
262402 { line . oldLine ?? '' }
@@ -297,6 +437,60 @@ export function DiffViewer({ filePath, original, modified, onApply, onReject }:
297437 )
298438 } ) }
299439 </ div >
440+
441+ { /* Floating Bottom Toolbar */ }
442+ { hunks . length > 0 && (
443+ < div className = "flex items-center justify-between px-4 py-2.5 border-t border-[var(--border)] bg-[var(--bg-elevated)] shrink-0" >
444+ { /* Left: Hunk Navigation */ }
445+ < div className = "flex items-center gap-2" >
446+ < button
447+ onClick = { ( ) => navigateHunk ( 'prev' ) }
448+ className = "inline-flex items-center justify-center w-7 h-7 rounded-full border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
449+ disabled = { hunks . length <= 1 }
450+ >
451+ < Icon icon = "lucide:chevron-left" width = { 14 } height = { 14 } />
452+ </ button >
453+ < span className = "text-[12px] font-medium text-[var(--text-secondary)] font-mono min-w-[60px] text-center" >
454+ { currentHunkIndex + 1 } / { hunks . length }
455+ </ span >
456+ < button
457+ onClick = { ( ) => navigateHunk ( 'next' ) }
458+ className = "inline-flex items-center justify-center w-7 h-7 rounded-full border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
459+ disabled = { hunks . length <= 1 }
460+ >
461+ < Icon icon = "lucide:chevron-right" width = { 14 } height = { 14 } />
462+ </ button >
463+ </ div >
464+
465+ { /* Center: Main Actions */ }
466+ < div className = "flex items-center gap-2" >
467+ < button
468+ onClick = { handleUndoAll }
469+ className = "inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-[12px] font-medium border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-colors"
470+ >
471+ Undo All
472+ < span className = "text-[10px] text-[var(--text-disabled)]" > ⌘⌫</ span >
473+ </ button >
474+ < button
475+ onClick = { handleKeepAll }
476+ className = "inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-[12px] font-medium transition-colors"
477+ style = { {
478+ backgroundColor : 'color-mix(in srgb, var(--color-additions) 20%, transparent)' ,
479+ borderColor : 'color-mix(in srgb, var(--color-additions) 40%, transparent)' ,
480+ color : 'var(--color-additions)' ,
481+ borderWidth : '1px' ,
482+ borderStyle : 'solid' ,
483+ } }
484+ >
485+ Keep All
486+ < span className = "text-[10px] opacity-70" > ⌘⏎</ span >
487+ </ button >
488+ </ div >
489+
490+ { /* Right: Spacer for balance */ }
491+ < div className = "w-[120px]" />
492+ </ div >
493+ ) }
300494 </ div >
301495 )
302496}
0 commit comments