1- import React , { useState , useEffect } from 'react' ;
1+ import React , { useState , useEffect , useRef , useCallback } from 'react' ;
22import { Icons } from './Icons' ;
33import './ChangesPanel.css' ;
44
@@ -57,6 +57,18 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
5757 return localStorage . getItem ( COMMIT_MESSAGE_STORAGE_KEY ) ?? '' ;
5858 } ) ;
5959 const [ excludedFiles , setExcludedFiles ] = useState < Set < string > > ( new Set ( ) ) ;
60+ const [ activeFilePath , setActiveFilePath ] = useState < string | null > ( null ) ;
61+ const listRef = useRef < HTMLDivElement | null > ( null ) ;
62+ const itemRefs = useRef < Map < string , HTMLDivElement > > ( new Map ( ) ) ;
63+ const shouldAutoScrollRef = useRef ( false ) ;
64+
65+ const setItemRef = useCallback ( ( path : string , element : HTMLDivElement | null ) => {
66+ if ( element ) {
67+ itemRefs . current . set ( path , element ) ;
68+ return ;
69+ }
70+ itemRefs . current . delete ( path ) ;
71+ } , [ ] ) ;
6072
6173 useEffect ( ( ) => {
6274 if ( typeof window === 'undefined' ) return ;
@@ -83,6 +95,27 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
8395 } ) ;
8496 } , [ files ] ) ;
8597
98+ useEffect ( ( ) => {
99+ if ( files . length === 0 ) {
100+ setActiveFilePath ( null ) ;
101+ return ;
102+ }
103+
104+ if ( ! activeFilePath || ! files . some ( ( file ) => file . path === activeFilePath ) ) {
105+ setActiveFilePath ( files [ 0 ] . path ) ;
106+ }
107+ } , [ files , activeFilePath ] ) ;
108+
109+ useEffect ( ( ) => {
110+ if ( ! activeFilePath || ! shouldAutoScrollRef . current ) {
111+ return ;
112+ }
113+
114+ const item = itemRefs . current . get ( activeFilePath ) ;
115+ item ?. scrollIntoView ( { block : 'nearest' } ) ;
116+ shouldAutoScrollRef . current = false ;
117+ } , [ activeFilePath ] ) ;
118+
86119 const toggleExcludedFile = ( path : string , e : React . MouseEvent ) => {
87120 e . stopPropagation ( ) ;
88121 setExcludedFiles ( prev => {
@@ -96,6 +129,55 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
96129 } ) ;
97130 } ;
98131
132+ const navigateByKey = useCallback ( ( key : string ) : boolean => {
133+ if ( ! [ 'ArrowDown' , 'ArrowUp' , 'Enter' ] . includes ( key ) ) {
134+ return false ;
135+ }
136+
137+ if ( files . length === 0 ) {
138+ return true ;
139+ }
140+
141+ const currentIndex = Math . max ( 0 , files . findIndex ( ( file ) => file . path === activeFilePath ) ) ;
142+
143+ if ( key === 'ArrowDown' ) {
144+ const nextIndex = Math . min ( files . length - 1 , currentIndex + 1 ) ;
145+ setActiveFilePath ( files [ nextIndex ] . path ) ;
146+ shouldAutoScrollRef . current = true ;
147+ return true ;
148+ }
149+
150+ if ( key === 'ArrowUp' ) {
151+ const prevIndex = Math . max ( 0 , currentIndex - 1 ) ;
152+ setActiveFilePath ( files [ prevIndex ] . path ) ;
153+ shouldAutoScrollRef . current = true ;
154+ return true ;
155+ }
156+
157+ if ( key === 'Enter' && activeFilePath ) {
158+ onFileClick ( activeFilePath ) ;
159+ listRef . current ?. blur ( ) ;
160+ return true ;
161+ }
162+
163+ return false ;
164+ } , [ activeFilePath , files , onFileClick ] ) ;
165+
166+ const handleListKeyDown = useCallback ( ( event : React . KeyboardEvent < HTMLDivElement > ) => {
167+ const handled = navigateByKey ( event . key ) ;
168+ if ( handled ) {
169+ event . preventDefault ( ) ;
170+ event . stopPropagation ( ) ;
171+ }
172+ } , [ navigateByKey ] ) ;
173+
174+ const handleListMouseDown = useCallback ( ( event : React . MouseEvent < HTMLDivElement > ) => {
175+ if ( event . button !== 0 ) {
176+ return ;
177+ }
178+ listRef . current ?. focus ( ) ;
179+ } , [ ] ) ;
180+
99181 const filesToCommit = files
100182 . map ( ( file ) => file . path )
101183 . filter ( ( path ) => ! excludedFiles . has ( path ) ) ;
@@ -263,17 +345,31 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
263345 </ div >
264346 </ div >
265347
266- < div className = "changes-list" >
348+ < div
349+ ref = { listRef }
350+ className = "changes-list"
351+ role = "listbox"
352+ tabIndex = { 0 }
353+ aria-label = "Changed files"
354+ onKeyDown = { handleListKeyDown }
355+ onMouseDown = { handleListMouseDown }
356+ >
267357 { files . length === 0 ? (
268358 < div className = "changes-empty" >
269359 No changes
270360 </ div >
271361 ) : (
272362 files . map ( ( file ) => (
273363 < div
364+ ref = { ( element ) => setItemRef ( file . path , element ) }
274365 key = { file . path }
275- className = { `changes-item ${ excludedFiles . has ( file . path ) ? 'excluded' : '' } ` }
276- onClick = { ( ) => onFileClick ( file . path ) }
366+ className = { `changes-item ${ activeFilePath === file . path ? 'active' : '' } ${ excludedFiles . has ( file . path ) ? 'excluded' : '' } ` }
367+ onClick = { ( ) => {
368+ setActiveFilePath ( file . path ) ;
369+ onFileClick ( file . path ) ;
370+ } }
371+ role = "option"
372+ aria-selected = { activeFilePath === file . path }
277373 >
278374 < div className = "changes-file-info" >
279375 < div className = "changes-file-main" >
0 commit comments