Skip to content

Commit c4d5950

Browse files
refactor(mobile): pull request screens use a file-jump store and handler work (#25525)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8e1bb03 commit c4d5950

8 files changed

Lines changed: 121 additions & 74 deletions

‎apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,10 @@ function setup(integrated = false) {
213213
focusIndex: -1,
214214
isIos: true,
215215
nativeView,
216-
jumpStarted,
216+
jumpStarted: () => jumpStarted.current,
217+
setJumpStarted: (started) => {
218+
jumpStarted.current = started;
219+
},
217220
loadMore: () => {},
218221
onDebug: bridge.onDebug,
219222
});

‎apps/mobile/src/features/pull-requests/PullRequestFiles.tsx‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { NativeScrollView } from "../../native/NativeScrollViewMarker";
22
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
33
import { AnimatedLegendList } from "@legendapp/list/reanimated";
4-
import {
5-
StackActions,
6-
useNavigation,
7-
useRoute,
8-
type StaticScreenProps,
9-
} from "@react-navigation/native";
4+
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
105
import type { PullRequestChangeBreakdown } from "@t3tools/contracts";
116
import { useState } from "react";
127
import { Platform, View } from "react-native";
@@ -36,6 +31,7 @@ import {
3631
} from "./usePullRequestDiff";
3732
import { usePullRequestFileFilter } from "./usePullRequestFileFilter";
3833
import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed";
34+
import { pullRequestFileJumpScope, requestPullRequestFileJump } from "./pullRequestFileJump";
3935

4036
const BREAKDOWN_LABELS = {
4137
code: "Code",
@@ -403,7 +399,7 @@ function DirectoryRow(props: {
403399
export function PullRequestFilesSheet({ route }: StaticScreenProps<PullRequestDiffParams>) {
404400
const params = route.params;
405401
const navigation = useNavigation();
406-
const fileSelectionId = useRoute().key;
402+
const jumpScope = pullRequestFileJumpScope(params);
407403
const files = useChangedFiles(params);
408404
const { diff, filter, viewed, parsed, loading } = files;
409405
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
@@ -463,15 +459,16 @@ export function PullRequestFilesSheet({ route }: StaticScreenProps<PullRequestDi
463459
<FileRow
464460
file={item.file}
465461
viewed={viewed.isViewed(item.file.path)}
466-
onPress={() =>
462+
onPress={() => {
463+
requestPullRequestFileJump(jumpScope, item.file.path);
467464
navigation.dispatch(
468465
StackActions.popTo("PullRequestFile", {
469466
...params,
470467
path: item.file.path,
471-
fileSelectionId,
468+
focus: undefined,
472469
}),
473-
)
474-
}
470+
);
471+
}}
475472
/>
476473
)}
477474
</Animated.View>

‎apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx‎

Lines changed: 58 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/cont
5050
import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading";
5151
import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink";
5252
import { pullRequestStatus } from "./pullRequestStatus";
53-
import { use, useEffect, useMemo, useRef, useState } from "react";
53+
import { use, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
5454
import {
5555
Linking,
5656
Platform,
@@ -123,6 +123,7 @@ import { PullRequestMergeControls } from "./PullRequestMergeControls";
123123
import { PullRequestStackCard } from "./PullRequestStackCard";
124124
import { usePullRequestStack, type PullRequestStackState } from "./usePullRequestStack";
125125
import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFallback";
126+
import { pullRequestFileJumpScope, subscribePullRequestFileJump } from "./pullRequestFileJump";
126127
import {
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
856862
type 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
}}

‎apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,6 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
207207
...route.params,
208208
path: excerpt.path,
209209
focus: { side: excerpt.side, start: excerpt.start, end: excerpt.end },
210-
fileSelectionId: `guide:${position}:${excerpt.path}:${excerpt.start}`,
211210
}),
212211
)
213212
}

‎apps/mobile/src/features/pull-requests/pullRequestDiffDebug.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ it.each([true, false])(
3535
const scheduler = createReviewDiffHighlightScheduler(sourceRequest);
3636
const scrollToFile = vi.fn(async () => {});
3737
const loadMore = vi.fn();
38-
const jumpStarted = { current: true };
3938
const onDebug: NonNullable<NativeReviewDiffViewProps["onDebug"]> = ({ nativeEvent }) => {
4039
if (nativeEvent.message === "visible-range")
4140
scheduler.update({
@@ -50,7 +49,8 @@ it.each([true, false])(
5049
focusIndex: -1,
5150
isIos,
5251
nativeView: { current: { scrollToFile, scrollToTop: vi.fn(async () => {}) } },
53-
jumpStarted,
52+
jumpStarted: () => true,
53+
setJumpStarted: () => {},
5454
onDebug,
5555
loadMore,
5656
});

‎apps/mobile/src/features/pull-requests/pullRequestDiffDebug.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export function handlePullRequestDiffDebugEvent(
2525
readonly focusIndex: number;
2626
readonly isIos: boolean;
2727
readonly nativeView: RefObject<NativeReviewDiffViewHandle | null>;
28-
readonly jumpStarted: RefObject<boolean>;
28+
readonly jumpStarted: () => boolean;
29+
readonly setJumpStarted: (started: boolean) => void;
2930
readonly onDebug: NonNullable<NativeReviewDiffViewProps["onDebug"]>;
3031
readonly loadMore: () => void;
3132
},
@@ -46,14 +47,14 @@ export function handlePullRequestDiffDebugEvent(
4647
if (drawn) input.onDebug(event);
4748
if (
4849
input.targetFileId &&
49-
!input.jumpStarted.current &&
50+
!input.jumpStarted() &&
5051
input.nativeView.current &&
5152
(!input.isIos || decoded)
5253
) {
53-
input.jumpStarted.current = true;
54+
input.setJumpStarted(true);
5455
if (input.focusIndex >= 0) return;
5556
void input.nativeView.current.scrollToFile(input.targetFileId, false).catch(() => {
56-
input.jumpStarted.current = false;
57+
input.setJumpStarted(false);
5758
});
5859
return;
5960
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { requestPullRequestFileJump, subscribePullRequestFileJump } from "./pullRequestFileJump";
3+
4+
describe("pull request file jumps", () => {
5+
it("delivers a pick to the screens of its scope, every time it is made", () => {
6+
const seen: string[] = [];
7+
const other: string[] = [];
8+
const stop = subscribePullRequestFileJump("a", (path) => seen.push(path));
9+
const stopOther = subscribePullRequestFileJump("b", (path) => other.push(path));
10+
requestPullRequestFileJump("a", "src/x.ts");
11+
requestPullRequestFileJump("a", "src/x.ts");
12+
stop();
13+
requestPullRequestFileJump("a", "src/y.ts");
14+
stopOther();
15+
expect(seen).toEqual(["src/x.ts", "src/x.ts"]);
16+
expect(other).toEqual([]);
17+
});
18+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { PullRequestDiffParams } from "./usePullRequestDiff";
2+
import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
3+
4+
// The changed-files sheet hands its pick to the diff screen beneath it. Picking the file the
5+
// screen already shows still jumps back to it, which a route param that did not change could not
6+
// say. A screen that is not mounted yet opens at its `path` param instead.
7+
type Listener = (path: string) => void;
8+
const listeners = new Map<string, Set<Listener>>();
9+
10+
export function pullRequestFileJumpScope(params: PullRequestDiffParams): string {
11+
return JSON.stringify([pullRequestReviewKey(params), params.commit ?? null]);
12+
}
13+
14+
export function requestPullRequestFileJump(scope: string, path: string): void {
15+
for (const listener of listeners.get(scope) ?? []) listener(path);
16+
}
17+
18+
export function subscribePullRequestFileJump(scope: string, listener: Listener): () => void {
19+
let scoped = listeners.get(scope);
20+
if (!scoped) listeners.set(scope, (scoped = new Set()));
21+
scoped.add(listener);
22+
return () => {
23+
scoped.delete(listener);
24+
if (scoped.size === 0) listeners.delete(scope);
25+
};
26+
}

0 commit comments

Comments
 (0)