@@ -50,7 +50,7 @@ import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/cont
5050import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading" ;
5151import { copyPullRequestLink , sharePullRequestLink } from "./pullRequestLink" ;
5252import { pullRequestStatus } from "./pullRequestStatus" ;
53- import { use , useEffect , useMemo , useRef , useState } from "react" ;
53+ import { use , useEffect , useEffectEvent , useMemo , useRef , useState } from "react" ;
5454import {
5555 Linking ,
5656 Platform ,
@@ -123,6 +123,7 @@ import { PullRequestMergeControls } from "./PullRequestMergeControls";
123123import { PullRequestStackCard } from "./PullRequestStackCard" ;
124124import { usePullRequestStack , type PullRequestStackState } from "./usePullRequestStack" ;
125125import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback" ;
126+ import { pullRequestFileJumpScope , subscribePullRequestFileJump } from "./pullRequestFileJump" ;
126127import {
127128 capitalizeTerm ,
128129 sourceControlClients ,
@@ -343,16 +344,26 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
343344 const params = route . params ;
344345 usePullRequestEnvironmentFallback ( params ) ;
345346 const connected = usePullRequestConnected ( params . environmentId ) ;
346- const [ section , setSection ] = useState < "overview" | "activity" | "guide" > ( "overview" ) ;
347- const requestedSection = useRef ( section ) ;
347+ const [ chosenSection , setSection ] = useState < "overview" | "activity" | "guide" > ( "overview" ) ;
348+ const requestedSection = useRef ( chosenSection ) ;
348349 const [ listAnimationsReady , setListAnimationsReady ] = useState ( false ) ;
349350 const [ descriptionExpanded , setDescriptionExpanded ] = useState ( false ) ;
350351 const filesContent = usePullRequestFilesSection ( params ) ;
351352 const guidesEnabled = useReviewGuidesEnabled ( params ) ;
353+ // The guide tab goes away while guides are off and overview stands in. The pick is kept, so
354+ // guides coming back (a settings reload, a reconnect) return to the guide the user chose.
355+ const shown = ( tab : typeof chosenSection ) =>
356+ ! guidesEnabled && tab === "guide" ? "overview" : tab ;
357+ const section = shown ( chosenSection ) ;
352358 const [ activityFilter , setActivityFilter ] = useState < ActivityFilter > ( DEFAULT_ACTIVITY_FILTER ) ;
353359 const activityContent = usePullRequestActivityRows ( params , activityFilter ) ;
354360 const scrollOffsets = useRef < Record < string , number > > ( { } ) ;
355- const currentScrollOffset = useRef ( - insets . top ) ;
361+ // The offset of the tab last shown, and which tab that was: a tab standing in for a hidden guide
362+ // has its own offset, which only its next scroll would record here.
363+ const currentScrollOffset = useRef < { tab : typeof section ; offset : number } > ( {
364+ tab : "overview" ,
365+ offset : - insets . top ,
366+ } ) ;
356367 const bodyY = useRef ( 0 ) ;
357368 const [ headerHeight , setHeaderHeight ] = useState ( 0 ) ;
358369 const [ viewportHeight , setViewportHeight ] = useState ( 0 ) ;
@@ -371,27 +382,20 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
371382 } ) ;
372383 const selectSection = ( next : typeof section ) => {
373384 if ( next === section ) return ;
374- scrollOffsets . current [ section ] = currentScrollOffset . current ;
385+ const from = shown ( requestedSection . current ) ;
386+ const current =
387+ currentScrollOffset . current . tab === from
388+ ? currentScrollOffset . current . offset
389+ : ( scrollOffsets . current [ from ] ?? - insets . top ) ;
390+ scrollOffsets . current [ section ] = current ;
375391 const headerEnd = Math . max ( - insets . top , bodyY . current - insets . top ) ;
376- const offset = pullRequestTabOffset (
377- currentScrollOffset . current ,
378- scrollOffsets . current [ next ] ,
379- headerEnd ,
380- ) ;
392+ const offset = pullRequestTabOffset ( current , scrollOffsets . current [ next ] , headerEnd ) ;
381393 requestedSection . current = next ;
382394 scrollOffsets . current [ next ] = offset ;
383- currentScrollOffset . current = offset ;
395+ currentScrollOffset . current = { tab : next , offset } ;
384396 scrollViews . current [ next ] ?. scrollToOffset ( { offset, animated : false } ) ;
385397 setSection ( next ) ;
386398 } ;
387- useEffect ( ( ) => {
388- if ( ! guidesEnabled && section === "guide" ) {
389- requestedSection . current = "overview" ;
390- currentScrollOffset . current = scrollOffsets . current . overview ?? - insets . top ;
391- // oxlint-disable-next-line react/set-state-in-effect -- When guides turn off, the tab and its saved scroll offset must switch to overview together.
392- setSection ( "overview" ) ;
393- }
394- } , [ guidesEnabled , section , insets . top , currentScrollOffset , requestedSection , scrollOffsets ] ) ;
395399 const tabContent = activityContent ;
396400 const filesY = useRef ( 0 ) ;
397401 const descriptionY = useRef ( 0 ) ;
@@ -661,7 +665,8 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
661665 onScroll = { ( event ) => {
662666 const offset = event . nativeEvent . contentOffset . y ;
663667 scrollOffsets . current [ tab ] = offset ;
664- if ( requestedSection . current === tab ) currentScrollOffset . current = offset ;
668+ if ( shown ( requestedSection . current ) === tab )
669+ currentScrollOffset . current = { tab, offset } ;
665670 } }
666671 className = "flex-1"
667672 renderScrollComponent = {
@@ -737,7 +742,8 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
737742 filesY . current = event . nativeEvent . layout . y ;
738743 if ( pendingFilesJump . current ) {
739744 requestAnimationFrame ( ( ) => {
740- if ( requestedSection . current === "overview" ) jumpToFiles ( ) ;
745+ if ( shown ( requestedSection . current ) === "overview" )
746+ jumpToFiles ( ) ;
741747 } ) ;
742748 }
743749 } }
@@ -856,7 +862,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
856862type PullRequestFileRouteProps = StaticScreenProps <
857863 PullRequestDiffParams & {
858864 readonly path : string ;
859- readonly fileSelectionId ?: string ;
860865 readonly replacingDraftId ?: string ;
861866 readonly focus ?: GuideDiffFocus ;
862867 }
@@ -982,40 +987,35 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) {
982987 const [ collapsedIds , setCollapsedIds ] = useState < readonly string [ ] > ( EMPTY_IDS ) ;
983988 const [ visibleFileId , setVisibleFileId ] = useState < string | null > ( null ) ;
984989 const nativeView = useRef < NativeReviewDiffViewHandle > ( null ) ;
985- // Native applies the entry destination before its first viewport. Imperative
986- // jumps below are for subsequent selections and filter changes only.
987- const jumpStarted = useRef ( true ) ;
988- const lastSelection = useRef ( { path : route . params . path , id : route . params . fileSelectionId } ) ;
989- useEffect ( ( ) => {
990- if (
991- lastSelection . current . path === route . params . path &&
992- lastSelection . current . id === route . params . fileSelectionId
993- )
994- return ;
995- lastSelection . current = { path : route . params . path , id : route . params . fileSelectionId } ;
996- if ( route . params . focus ) navigation . setParams ( { focus : undefined } ) ;
997- setJumpPath ( route . params . path ) ;
990+ // The filter the current jump was made under. Hiding or showing test files replaces the rows,
991+ // so a jump made under the other filter has to run again. Native applies the entry
992+ // destination before its first viewport, so the first one counts as made.
993+ const jumpedUnder = useRef < boolean | null > ( filter . hideTestFiles ) ;
994+ const hasTestFiles =
995+ result . parsed . kind === "files" &&
996+ result . parsed . files . some ( ( file ) => isPullRequestTestFile ( file . path ) ) ;
997+ const jumpStarted = ( ) =>
998+ jumpedUnder . current !== null && ( ! hasTestFiles || jumpedUnder . current === filter . hideTestFiles ) ;
999+ const scrollToFile = ( fileId : string ) => {
1000+ if ( ! nativeView . current ) return ;
1001+ jumpedUnder . current = filter . hideTestFiles ;
1002+ void nativeView . current . scrollToFile ( fileId , false ) . catch ( ( ) => {
1003+ jumpedUnder . current = null ;
1004+ } ) ;
1005+ } ;
1006+ const jumpScope = pullRequestFileJumpScope ( route . params ) ;
1007+ const jumpToFile = useEffectEvent ( ( path : string ) => {
1008+ setJumpPath ( path ) ;
9981009 setSelectedRowId ( null ) ;
999- jumpStarted . current = false ;
1000- const file = data . files . find ( ( entry ) => entry . path === route . params . path ) ;
1001- if ( ! file || ! nativeView . current ) return ;
1010+ jumpedUnder . current = null ;
1011+ const file = data . files . find ( ( entry ) => entry . path === path ) ;
1012+ if ( ! file ) return ;
10021013 setCollapsedIds ( ( ids ) => ids . filter ( ( id ) => id !== file . id ) ) ;
1003- jumpStarted . current = true ;
1004- void nativeView . current . scrollToFile ( file . id , false ) . catch ( ( ) => {
1005- jumpStarted . current = false ;
1006- } ) ;
1007- } , [ route . params . path , route . params . fileSelectionId , route . params . focus , data . files , navigation ] ) ;
1008- const appliedFilter = useRef ( filter . hideTestFiles ) ;
1009- useEffect ( ( ) => {
1010- if ( appliedFilter . current === filter . hideTestFiles ) return ;
1011- appliedFilter . current = filter . hideTestFiles ;
1012- if (
1013- result . parsed . kind === "files" &&
1014- result . parsed . files . some ( ( file ) => isPullRequestTestFile ( file . path ) )
1015- ) {
1016- jumpStarted . current = false ;
1017- }
1018- } , [ filter . hideTestFiles , result . parsed ] ) ;
1014+ scrollToFile ( file . id ) ;
1015+ } ) ;
1016+ // Subscribed for the whole screen, so a pick made while the diff is still loading is kept for
1017+ // the first draw.
1018+ useEffect ( ( ) => subscribePullRequestFileJump ( jumpScope , ( path ) => jumpToFile ( path ) ) , [ jumpScope ] ) ;
10191019 const { nativeReviewDiffStyle } = useAppearanceCodeSurface ( ) ;
10201020 const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences ( ) ;
10211021 const bridge = useNativeReviewDiffBridge ( {
@@ -1040,6 +1040,9 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) {
10401040 isIos : Platform . OS === "ios" ,
10411041 nativeView,
10421042 jumpStarted,
1043+ setJumpStarted : ( started ) => {
1044+ jumpedUnder . current = started ? filter . hideTestFiles : null ;
1045+ } ,
10431046 onDebug : bridge . onDebug ,
10441047 loadMore,
10451048 } ) ;
@@ -1196,7 +1199,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) {
11961199 onPress = { ( ) => {
11971200 result . refresh ( ) ;
11981201 setDiffHead ( { scopeKey, sha : detail . data ?. headSha ?? null } ) ;
1199- jumpStarted . current = false ;
1202+ jumpedUnder . current = null ;
12001203 setSelectedRowId ( null ) ;
12011204 setCollapsedIds ( EMPTY_IDS ) ;
12021205 } }
0 commit comments