Skip to content

Commit 88fd2ad

Browse files
committed
feat: VS Code-style inline diff approval toolbar
- Floating bottom toolbar with hunk navigation (← 1/8 →) - Undo All (⌘⌫) / Keep All (⌘⏎) bulk actions - Per-hunk 'Undo ⌘N' / 'Keep ⌘Y' inline buttons - Keyboard shortcuts: ⌘Y/⌘N/J/K/⌘⏎/⌘⌫ - Colored gutter indicators (green additions, red deletions) - Current hunk highlighting with auto-scroll - Auto-focus on mount for immediate keyboard control
1 parent 3f3844d commit 88fd2ad

1 file changed

Lines changed: 222 additions & 28 deletions

File tree

components/diff-viewer.tsx

Lines changed: 222 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useMemo, useState, useCallback } from 'react'
3+
import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
44
import { Icon } from '@iconify/react'
55

66
interface 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

Comments
 (0)