1- import React , { useCallback , useEffect , useMemo , useRef } from 'react' ;
1+ import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
22import { Diff , Hunk , getChangeKey , parseDiff , textLinesToHunk , type ChangeData , type DiffType , type HunkData } from 'react-diff-view' ;
33import 'react-diff-view/style/index.css' ;
44import { API } from '../../../utils/api' ;
@@ -16,6 +16,8 @@ type FileModel = {
1616 > ;
1717} ;
1818
19+ type HunkKind = 'added' | 'deleted' | 'modified' ;
20+
1921function toFilePath ( raw : { newPath : string ; oldPath : string } ) {
2022 return ( raw . newPath || raw . oldPath || '(unknown)' ) . trim ( ) || '(unknown)' ;
2123}
@@ -40,6 +42,19 @@ function hunkSignature(hunk: HunkData): string {
4042 return parts . join ( '\n' ) ;
4143}
4244
45+ function hunkKind ( hunk : HunkData ) : HunkKind | null {
46+ const changes = hunk . changes as ChangeData [ ] ;
47+ let hasInsert = false ;
48+ let hasDelete = false ;
49+ for ( const change of changes ) {
50+ if ( ( change as any ) . isInsert ) hasInsert = true ;
51+ else if ( ( change as any ) . isDelete ) hasDelete = true ;
52+ }
53+ if ( ! hasInsert && ! hasDelete ) return null ;
54+ if ( hasInsert && hasDelete ) return 'modified' ;
55+ return hasInsert ? 'added' : 'deleted' ;
56+ }
57+
4358function normalizeHunks ( hunks : HunkData [ ] ) : HunkData [ ] {
4459 return hunks . map ( ( h ) => {
4560 const parsed = parseHunkHeader ( h . content ) ;
@@ -99,6 +114,16 @@ export const ZedDiffViewer: React.FC<{
99114 onChanged ?: ( ) => void ;
100115} > = ( { diff, className, sessionId, currentScope, stagedDiff, unstagedDiff, fileSources, scrollToFilePath, fileOrder, onChanged } ) => {
101116 const fileHeaderRefs = useRef < Map < string , HTMLElement > > ( new Map ( ) ) ;
117+ const [ pendingHunkKeys , setPendingHunkKeys ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
118+
119+ const setPending = useCallback ( ( key : string , next : boolean ) => {
120+ setPendingHunkKeys ( ( prev ) => {
121+ const copy = new Set ( prev ) ;
122+ if ( next ) copy . add ( key ) ;
123+ else copy . delete ( key ) ;
124+ return copy ;
125+ } ) ;
126+ } , [ ] ) ;
102127
103128 const stagedHunkHeaderBySig = useMemo ( ( ) => {
104129 if ( ! stagedDiff || stagedDiff . trim ( ) === '' ) return new Map < string , Map < string , string > > ( ) ;
@@ -192,29 +217,42 @@ export const ZedDiffViewer: React.FC<{
192217 } , [ scrollToFilePath , scrollToFile ] ) ;
193218
194219 const stageOrUnstageHunk = useCallback (
195- async ( filePath : string , isStaging : boolean , hunkHeader : string ) => {
220+ async ( filePath : string , isStaging : boolean , hunkHeader : string , hunkKey : string ) => {
196221 if ( ! sessionId ) return ;
197222 try {
223+ setPending ( hunkKey , true ) ;
198224 await API . sessions . stageHunk ( sessionId , { filePath, isStaging, hunkHeader } ) ;
199225 onChanged ?.( ) ;
200226 } catch ( err ) {
201227 console . error ( `[Diff] Failed to ${ isStaging ? 'stage' : 'unstage' } hunk` , { filePath, hunkHeader, err } ) ;
228+ } finally {
229+ setPending ( hunkKey , false ) ;
230+ // Prevent focus from sticking to the old hunk after staging (avoids "hover controls" lingering).
231+ if ( document . activeElement instanceof HTMLElement ) {
232+ document . activeElement . blur ( ) ;
233+ }
202234 }
203235 } ,
204- [ sessionId , onChanged ]
236+ [ sessionId , onChanged , setPending ]
205237 ) ;
206238
207239 const restoreHunk = useCallback (
208- async ( filePath : string , scope : 'staged' | 'unstaged' , hunkHeader : string ) => {
240+ async ( filePath : string , scope : 'staged' | 'unstaged' , hunkHeader : string , hunkKey : string ) => {
209241 if ( ! sessionId ) return ;
210242 try {
243+ setPending ( hunkKey , true ) ;
211244 await API . sessions . restoreHunk ( sessionId , { filePath, scope, hunkHeader } ) ;
212245 onChanged ?.( ) ;
213246 } catch ( err ) {
214247 console . error ( '[Diff] Failed to restore hunk' , { filePath, hunkHeader, err } ) ;
248+ } finally {
249+ setPending ( hunkKey , false ) ;
250+ if ( document . activeElement instanceof HTMLElement ) {
251+ document . activeElement . blur ( ) ;
252+ }
215253 }
216254 } ,
217- [ sessionId , onChanged ]
255+ [ sessionId , onChanged , setPending ]
218256 ) ;
219257
220258 if ( ! diff || diff . trim ( ) === '' || files . length === 0 ) {
@@ -304,30 +342,38 @@ export const ZedDiffViewer: React.FC<{
304342 ? 'st-hunk-status--unstaged'
305343 : 'st-hunk-status--unknown' ;
306344
345+ const kind = hunkKind ( hunk ) ;
346+ const kindClass =
347+ kind === 'added' ? 'st-hunk-kind--added' : kind === 'deleted' ? 'st-hunk-kind--deleted' : 'st-hunk-kind--modified' ;
348+ const hunkKey = ( hunk as any ) . __st_hunkKey as string ;
349+ const isPending = pendingHunkKeys . has ( hunkKey ) ;
350+
307351 const changeKey = getChangeKey ( first ) ;
308352
309353 const element = (
310- < div data-testid = "diff-hunk-controls" className = { `st-diff-hunk-actions-anchor ${ statusClass } ` } >
354+ < div data-testid = "diff-hunk-controls" className = { `st-diff-hunk-actions-anchor ${ statusClass } ${ kindClass } ` } >
311355 < div className = "st-diff-hunk-actions" >
312356 < button
313357 type = "button"
314358 data-testid = "diff-hunk-stage"
315- disabled = { ! canStageOrUnstage }
316- onClick = { ( ) => stageOrUnstageHunk ( file . path , stageLabel === 'Stage' , stageHeader ) }
359+ disabled = { ! canStageOrUnstage || isPending }
360+ onMouseDown = { ( e ) => e . preventDefault ( ) }
361+ onClick = { ( ) => stageOrUnstageHunk ( file . path , stageLabel === 'Stage' , stageHeader , hunkKey ) }
317362 className = "st-diff-hunk-btn"
318363 title = { canStageOrUnstage ? `${ stageLabel } hunk` : 'Unavailable' }
319364 >
320- { stageLabel }
365+ { isPending ? '…' : stageLabel }
321366 </ button >
322367 < button
323368 type = "button"
324369 data-testid = "diff-hunk-restore"
325- disabled = { ! canRestore }
326- onClick = { ( ) => restoreHunk ( file . path , restoreScope , stageHeader ) }
370+ disabled = { ! canRestore || isPending }
371+ onMouseDown = { ( e ) => e . preventDefault ( ) }
372+ onClick = { ( ) => restoreHunk ( file . path , restoreScope , stageHeader , hunkKey ) }
327373 className = "st-diff-hunk-btn"
328374 title = { canRestore ? 'Restore hunk' : 'Unavailable' }
329375 >
330- Restore
376+ { isPending ? '…' : ' Restore' }
331377 </ button >
332378 </ div >
333379 </ div >
@@ -350,6 +396,8 @@ export const ZedDiffViewer: React.FC<{
350396 --st-diff-line-height: 20px;
351397 /* Zed-like hunk padding (blank line feel). */
352398 --st-diff-hunk-pad-y: 30px;
399+ /* Zed-like hunk rounding. */
400+ --st-diff-hunk-radius: 16px;
353401 }
354402
355403 .st-diff-table.diff { table-layout: fixed; width: 100%; }
@@ -362,19 +410,25 @@ export const ZedDiffViewer: React.FC<{
362410 /* Zed-like: only "edit hunks" are treated as blocks. */
363411 .st-diff-table .diff-hunk { position: relative; }
364412 .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) {
365- --st-hunk-marker-color: var(--st-diff-modified-marker);
366- --st-hunk-bg: var(--st-diff-hunk-bg);
367- --st-hunk-frame-color: var(--st-accent);
413+ --st-hunk-color: var(--st-diff-modified-marker);
414+ --st-hunk-marker-color: var(--st-hunk-color);
415+ --st-hunk-solid-bg: color-mix(in srgb, var(--st-hunk-color) 12%, transparent);
416+ --st-hunk-hollow-bg: color-mix(in srgb, var(--st-hunk-color) 7%, transparent);
417+ --st-hunk-bg: var(--st-hunk-solid-bg);
418+ --st-hunk-frame-color: var(--st-hunk-color);
368419 }
369- .st-diff-table .diff-hunk:has(.st-hunk-status--staged) {
370- --st-hunk-marker-color: var(--st-diff-added-marker);
371- --st-hunk-bg: color-mix(in srgb, var(--st-success) 12%, transparent);
372- --st-hunk-frame-color: var(--st-diff-added-marker);
420+ .st-diff-table .diff-hunk:has(.st-hunk-kind--added):has(.diff-code-insert, .diff-code-delete) {
421+ --st-hunk-color: var(--st-diff-added-marker);
373422 }
374- .st-diff-table .diff-hunk:has(.st-hunk-status--unstaged) {
375- --st-hunk-marker-color: var(--st-diff-modified-marker);
376- --st-hunk-bg: var(--st-diff-hunk-bg);
377- --st-hunk-frame-color: var(--st-accent);
423+ .st-diff-table .diff-hunk:has(.st-hunk-kind--deleted):has(.diff-code-insert, .diff-code-delete) {
424+ --st-hunk-color: var(--st-diff-deleted-marker);
425+ }
426+ .st-diff-table .diff-hunk:has(.st-hunk-kind--modified):has(.diff-code-insert, .diff-code-delete) {
427+ --st-hunk-color: var(--st-diff-modified-marker);
428+ }
429+ /* Zed-like hunk_style: staged is hollow by default (bordered, faded). */
430+ .st-diff-table .diff-hunk:has(.st-hunk-status--staged):has(.diff-code-insert, .diff-code-delete) {
431+ --st-hunk-bg: var(--st-hunk-hollow-bg);
378432 }
379433 .st-diff-table .diff-hunk:has(.st-hunk-status--unknown) {
380434 --st-hunk-marker-color: var(--st-text-faint);
@@ -406,8 +460,8 @@ export const ZedDiffViewer: React.FC<{
406460 .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete)::after {
407461 content: '';
408462 position: absolute;
409- inset: 0 ;
410- border-radius: 10px ;
463+ inset: 2px ;
464+ border-radius: var(--st-diff-hunk-radius) ;
411465 pointer-events: none;
412466 opacity: 0;
413467 z-index: 1;
@@ -420,8 +474,7 @@ export const ZedDiffViewer: React.FC<{
420474 .st-diff-table .diff-hunk:has(.st-hunk-status--staged):has(.diff-code-insert, .diff-code-delete)::after {
421475 opacity: 0.55;
422476 }
423- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover::after,
424- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):focus-within::after {
477+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover::after {
425478 opacity: 1;
426479 }
427480
@@ -437,26 +490,21 @@ export const ZedDiffViewer: React.FC<{
437490 .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:last-child td { border-bottom: 1px solid transparent; }
438491 .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr td:first-child { border-left: 1px solid transparent; }
439492 .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr td:last-child { border-right: 1px solid transparent; }
440- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:first-child td:first-child { border-top-left-radius: 10px ; }
441- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:first-child td:last-child { border-top-right-radius: 10px ; }
442- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:last-child td:first-child { border-bottom-left-radius: 10px ; }
443- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:last-child td:last-child { border-bottom-right-radius: 10px ; }
493+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:first-child td:first-child { border-top-left-radius: var(--st-diff-hunk-radius) ; }
494+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:first-child td:last-child { border-top-right-radius: var(--st-diff-hunk-radius) ; }
495+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:last-child td:first-child { border-bottom-left-radius: var(--st-diff-hunk-radius) ; }
496+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete) tr:last-child td:last-child { border-bottom-right-radius: var(--st-diff-hunk-radius) ; }
444497
445- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr td,
446- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):focus-within tr td {
498+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr td {
447499 background-image: linear-gradient(
448500 color-mix(in srgb, var(--st-hunk-bg) 90%, transparent),
449501 color-mix(in srgb, var(--st-hunk-bg) 90%, transparent)
450502 );
451503 }
452- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr:first-child td,
453- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):focus-within tr:first-child td { border-top-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
454- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr:last-child td,
455- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):focus-within tr:last-child td { border-bottom-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
456- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr td:first-child,
457- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):focus-within tr td:first-child { border-left-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
458- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr td:last-child,
459- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):focus-within tr td:last-child { border-right-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
504+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr:first-child td { border-top-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
505+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr:last-child td { border-bottom-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
506+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr td:first-child { border-left-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
507+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover tr td:last-child { border-right-color: color-mix(in srgb, var(--st-hunk-frame-color) 45%, transparent); }
460508
461509 /* Extra contrast on hover (more Zed-like). */
462510 .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover {
@@ -489,8 +537,7 @@ export const ZedDiffViewer: React.FC<{
489537 transition: opacity 120ms ease;
490538 z-index: 5;
491539 }
492- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover .st-diff-hunk-actions,
493- .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):focus-within .st-diff-hunk-actions {
540+ .st-diff-table .diff-hunk:has(.diff-code-insert, .diff-code-delete):hover .st-diff-hunk-actions {
494541 opacity: 1;
495542 pointer-events: auto;
496543 }
0 commit comments