@@ -9,22 +9,98 @@ import sleep from '../../../../helpers/sleep';
99import isGithub from '../../../../helpers/is-github' ;
1010
1111const featureId = features . getFeatureID ( import . meta. url ) ;
12+ const insightsTabSelectors = [
13+ 'nav[aria-label="Repository"] a[data-tab-item="insights"]' ,
14+ 'a.UnderlineNav-item#insights-tab' ,
15+ ] . join ( ', ' ) ;
16+ let highlightingListenerAttached = false ;
17+ let syncingPerceptorTab = false ;
18+ let navigationObserver : MutationObserver | null = null ;
19+ let observedRepositoryNavigation : HTMLElement | null = null ;
20+
21+ const getInsightsTab = ( ) => {
22+ const $tabs = $ ( insightsTabSelectors ) . filter ( ( _ , element ) => ! element . closest ( 'template' ) ) ;
23+ const $visibleTabs = $tabs . filter ( ':visible' ) ;
24+
25+ return ( $visibleTabs . length > 0 ? $visibleTabs : $tabs ) . first ( ) ;
26+ } ;
27+
28+ const getRepositoryNavigation = ( ) => {
29+ const $navigations = $ ( 'nav[aria-label="Repository"]' ) . filter ( ( _ , element ) => ! element . closest ( 'template' ) ) ;
30+ const $visibleNavigations = $navigations . filter ( ':visible' ) ;
31+
32+ return ( $visibleNavigations . length > 0 ? $visibleNavigations : $navigations ) . first ( ) ;
33+ } ;
34+
35+ const waitForMeasuredRepositoryNavigation = async ( ) : Promise < HTMLElement | null > => {
36+ const repositoryNavigation = ( await elementReady ( 'nav[aria-label="Repository"]' , {
37+ waitForChildren : false ,
38+ } ) ) as HTMLElement | null ;
39+
40+ if ( ! repositoryNavigation ) {
41+ return null ;
42+ }
43+
44+ if ( repositoryNavigation . dataset . overflowMeasured === 'true' ) {
45+ return repositoryNavigation ;
46+ }
47+
48+ await new Promise < void > ( ( resolve ) => {
49+ let observer : MutationObserver ;
50+ const timeout = window . setTimeout ( ( ) => {
51+ observer . disconnect ( ) ;
52+ resolve ( ) ;
53+ } , 1500 ) ;
54+
55+ observer = new MutationObserver ( ( ) => {
56+ if ( repositoryNavigation . dataset . overflowMeasured === 'true' ) {
57+ window . clearTimeout ( timeout ) ;
58+ observer . disconnect ( ) ;
59+ resolve ( ) ;
60+ }
61+ } ) ;
62+
63+ observer . observe ( repositoryNavigation , {
64+ attributes : true ,
65+ attributeFilter : [ 'data-overflow-measured' ] ,
66+ } ) ;
67+ } ) ;
68+
69+ return repositoryNavigation ;
70+ } ;
1271
1372const addPerceptorTab = async ( ) : Promise < void | false > => {
1473 // the creation of the Perceptor tab is based on the Insights tab
15- const insightsTab = await elementReady ( 'a.UnderlineNav-item[id="insights-tab"]' , { waitForChildren : false } ) ;
74+ await waitForMeasuredRepositoryNavigation ( ) ;
75+ await elementReady ( insightsTabSelectors , { waitForChildren : false } ) ;
76+ const $insightsTab = getInsightsTab ( ) ;
77+ const insightsTab = $insightsTab [ 0 ] as HTMLAnchorElement | undefined ;
1678 if ( ! insightsTab ) {
17- // if the selector failed to find the Insights tab
1879 return false ;
1980 }
20- const perceptorTab = insightsTab . cloneNode ( true ) as HTMLAnchorElement ;
81+
82+ $ ( `#${ featureId } ` ) . closest ( 'li' ) . remove ( ) ;
83+
84+ const insightTabListItem = insightsTab . closest ( 'li' ) ;
85+ const perceptorTabListItem =
86+ ( insightTabListItem ?. cloneNode ( true ) as HTMLElement | null ) ?? document . createElement ( 'li' ) ;
87+ const perceptorTab = ( perceptorTabListItem . querySelector ( 'a' ) ?? insightsTab . cloneNode ( true ) ) as HTMLAnchorElement ;
88+ if ( ! perceptorTabListItem . contains ( perceptorTab ) ) {
89+ perceptorTabListItem . appendChild ( perceptorTab ) ;
90+ }
91+
2192 delete perceptorTab . dataset . selectedLinks ;
93+ delete perceptorTab . dataset . reactNav ;
94+ delete perceptorTab . dataset . reactNavAnchor ;
95+ delete perceptorTab . dataset . hotkey ;
2296 perceptorTab . removeAttribute ( 'aria-current' ) ;
2397 perceptorTab . classList . remove ( 'selected' ) ;
24- const perceptorHref = `${ insightsTab . href } ?redirect=perceptor` ;
98+ const perceptorUrl = new URL ( insightsTab . href ) ;
99+ perceptorUrl . searchParams . set ( 'redirect' , 'perceptor' ) ;
100+ const perceptorHref = perceptorUrl . toString ( ) ;
25101 perceptorTab . href = perceptorHref ;
26102 perceptorTab . id = featureId ;
27- perceptorTab . setAttribute ( 'data-tab-item' , featureId ) ;
103+ perceptorTab . setAttribute ( 'data-tab-item' , 'perceptor' ) ;
28104 perceptorTab . setAttribute (
29105 'data-analytics-event' ,
30106 `{"category":"Underline navbar","action":"Click tab","label":"Perceptor","target":"UNDERLINE_NAV.TAB"}`
@@ -33,51 +109,26 @@ const addPerceptorTab = async (): Promise<void | false> => {
33109 perceptorTitle . text ( 'Perceptor' ) . attr ( 'data-content' , 'Perceptor' ) ;
34110
35111 // slot for any future counter function
36- const perceptorCounter = $ ( '[class=Counter]' , perceptorTab ) ;
112+ const perceptorCounter = $ ( '[class=Counter], [data-component="counter"] ' , perceptorTab ) ;
37113 perceptorCounter . attr ( 'id' , `${ featureId } -count` ) ;
38114
39115 // replace with the perceptor Icon
40116 $ ( 'svg.octicon' , perceptorTab ) . html ( iconSvgPath ) ;
41117
42118 // add the Perceptor tab to the tabs list
43- if ( ! insightsTab . parentElement ) {
44- return false ;
45- }
46- const tabContainer = document . createElement ( 'li' ) ;
47- tabContainer . appendChild ( perceptorTab ) ;
48- tabContainer . setAttribute ( 'data-view-component' , 'true' ) ;
49- tabContainer . className = 'd-inline-flex' ;
50- insightsTab . parentElement . after ( tabContainer ) ;
51-
52- // add to drop down menu (when the window is narrow enough some tabs are hidden into "···" menu)
53- const repoNavigationDropdown = await elementReady ( '.UnderlineNav-actions ul' ) ;
54- if ( ! repoNavigationDropdown ) {
119+ if ( ! insightTabListItem ?. parentElement ) {
55120 return false ;
56121 }
57- const insightsTabDataItem = $ ( 'li[data-menu-item$="insights-tab"]' , repoNavigationDropdown ) ;
58- const perceptorTabDataItem = insightsTabDataItem . clone ( true ) ;
59- perceptorTabDataItem . attr ( 'data-menu-item' , featureId ) ;
60- perceptorTabDataItem . children ( 'a' ) . attr ( {
61- href : perceptorHref ,
62- } ) ;
63- const perceptorSvgElement = perceptorTabDataItem
64- . children ( 'a' )
65- . find ( 'span.ActionListItem-visual.ActionListItem-visual--leading' )
66- . find ( 'svg' ) ;
67- perceptorSvgElement . attr ( 'class' , 'octicon octicon-perceptor' ) ;
68- perceptorSvgElement . html ( iconSvgPath ) ;
69- const perceptorTextElement = perceptorTabDataItem . children ( 'a' ) . find ( 'span.ActionListItem-label' ) ;
70- perceptorTextElement . text ( 'Perceptor' ) ;
71- insightsTabDataItem . after ( perceptorTabDataItem ) ;
122+ insightTabListItem . after ( perceptorTabListItem ) ;
72123 // Trigger a reflow to push the right-most tab into the overflow dropdown
73124 window . dispatchEvent ( new Event ( 'resize' ) ) ;
74125} ;
75126
76127const updatePerceptorTabHighlighting = async ( ) : Promise < void > => {
77- const insightsTab = $ ( '#insights-tab' ) ;
128+ const insightsTab = getInsightsTab ( ) ;
78129 const perceptorTab = $ ( `#${ featureId } ` ) ;
79130 // no operation needed
80- if ( ! isPerceptor ( ) ) return ;
131+ if ( ! isPerceptor ( ) || perceptorTab . length === 0 || insightsTab . length === 0 ) return ;
81132 // if perceptor tab
82133 if ( insightsTab . hasClass ( 'selected' ) ) {
83134 insightsTab . removeClass ( 'selected' ) ;
@@ -86,6 +137,11 @@ const updatePerceptorTabHighlighting = async (): Promise<void> => {
86137 perceptorTab . addClass ( 'selected' ) ;
87138 }
88139
140+ if ( insightsTab . attr ( 'aria-current' ) === 'page' ) {
141+ insightsTab . removeAttr ( 'aria-current' ) ;
142+ perceptorTab . attr ( 'aria-current' , 'page' ) ;
143+ }
144+
89145 const insightsTabSeletedLinks = insightsTab . attr ( 'data-selected-links' ) ;
90146 insightsTab . removeAttr ( 'data-selected-links' ) ;
91147 perceptorTab . attr ( 'data-selected-links' , 'pulse' ) ;
@@ -96,13 +152,62 @@ const updatePerceptorTabHighlighting = async (): Promise<void> => {
96152 perceptorTab . removeAttr ( 'data-selected-links' ) ;
97153} ;
98154
155+ const syncPerceptorTab = async ( ) : Promise < void > => {
156+ if ( syncingPerceptorTab ) {
157+ return ;
158+ }
159+
160+ syncingPerceptorTab = true ;
161+ try {
162+ await addPerceptorTab ( ) ;
163+ await updatePerceptorTabHighlighting ( ) ;
164+ } finally {
165+ syncingPerceptorTab = false ;
166+ }
167+ } ;
168+
169+ const observeRepositoryNavigation = async ( ) : Promise < void > => {
170+ const repositoryNavigation = await waitForMeasuredRepositoryNavigation ( ) ;
171+ if ( ! repositoryNavigation || repositoryNavigation === observedRepositoryNavigation ) {
172+ return ;
173+ }
174+
175+ navigationObserver ?. disconnect ( ) ;
176+ observedRepositoryNavigation = repositoryNavigation as HTMLElement ;
177+ navigationObserver = new MutationObserver ( async ( ) => {
178+ const navigation = getRepositoryNavigation ( ) [ 0 ] ;
179+ if ( ! navigation || syncingPerceptorTab ) {
180+ return ;
181+ }
182+
183+ const perceptorTab = document . getElementById ( featureId ) ;
184+ if ( ! perceptorTab ) {
185+ await syncPerceptorTab ( ) ;
186+ return ;
187+ }
188+
189+ if ( isPerceptor ( ) && perceptorTab . getAttribute ( 'aria-current' ) !== 'page' ) {
190+ await updatePerceptorTabHighlighting ( ) ;
191+ }
192+ } ) ;
193+
194+ navigationObserver . observe ( repositoryNavigation , {
195+ childList : true ,
196+ subtree : true ,
197+ } ) ;
198+ } ;
199+
99200const init = async ( ) : Promise < void > => {
100- await addPerceptorTab ( ) ;
201+ await syncPerceptorTab ( ) ;
202+ await observeRepositoryNavigation ( ) ;
101203 // TODO need a mechanism to remove extra listeners like this one
102204 // add event listener to update tab highlighting at each turbo:load event
103- document . addEventListener ( 'turbo:load' , async ( ) => {
104- await updatePerceptorTabHighlighting ( ) ;
105- } ) ;
205+ if ( ! highlightingListenerAttached ) {
206+ highlightingListenerAttached = true ;
207+ document . addEventListener ( 'turbo:load' , async ( ) => {
208+ await syncPerceptorTab ( ) ;
209+ } ) ;
210+ }
106211} ;
107212
108213features . add ( featureId , {
0 commit comments