| author | |
| committer | |
| log | 2e24ec44dbf3047ab248487ec1d05836e94775e4 |
| tree | 3277f26f5bc7d33bd8c8d39da9253db10547b4af |
| parent | 4ab4cdf452374502b87d0a83047c6cd25a9db7aa |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 308 insertions(+), 123 deletions(-)
apps/mobile/src/Stack.tsx+2| ... | ... | @@ -51,6 +51,7 @@ import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardi |
| 51 | 51 | import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; |
| 52 | 52 | import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; |
| 53 | 53 | import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; |
| 54 | import { selectedPullRequestKey } from "./features/pull-requests/pullRequestSelection"; | |
| 54 | 55 | import { |
| 55 | 56 | HardwareKeyboardCommandOverlay, |
| 56 | 57 | HardwareKeyboardCommandProvider, |
| ... | ... | @@ -658,6 +659,7 @@ function RootStackLayout(props: { |
| 658 | 659 | <AdaptiveWorkspaceLayout |
| 659 | 660 | pathname={workspaceLocation.pathname} |
| 660 | 661 | workspaceRouteKey={workspaceLocation.routeKey} |
| 662 | selectedPullRequestKey={selectedPullRequestKey(props.state)} | |
| 661 | 663 | > |
| 662 | 664 | {props.children} |
| 663 | 665 | <HardwareKeyboardCommandOverlay /> |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+6-1| ... | ... | @@ -220,6 +220,7 @@ export function AdaptiveWorkspaceLayout(props: { |
| 220 | 220 | readonly children: ReactNode; |
| 221 | 221 | readonly pathname: string; |
| 222 | 222 | readonly workspaceRouteKey: string | undefined; |
| 223 | readonly selectedPullRequestKey?: string | null; | |
| 223 | 224 | }) { |
| 224 | 225 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 225 | 226 | if (!AsyncResult.isSuccess(preferencesResult)) { |
| ... | ... | @@ -245,6 +246,7 @@ function AdaptiveWorkspaceLayoutContent( |
| 245 | 246 | readonly children: ReactNode; |
| 246 | 247 | readonly pathname: string; |
| 247 | 248 | readonly workspaceRouteKey: string | undefined; |
| 249 | readonly selectedPullRequestKey?: string | null; | |
| 248 | 250 | } & { |
| 249 | 251 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 250 | 252 | readonly savedThreadListFilters?: ThreadListFilters | undefined; |
| ... | ... | @@ -739,7 +741,10 @@ function AdaptiveWorkspaceLayoutContent( |
| 739 | 741 | /> |
| 740 | 742 | )} |
| 741 | 743 | > |
| 742 | <AndroidWorkspaceSidebar onRequestVisibility={revealPrimarySidebar}> | |
| 744 | <AndroidWorkspaceSidebar | |
| 745 | onRequestVisibility={revealPrimarySidebar} | |
| 746 | selectedPullRequestKey={props.selectedPullRequestKey ?? null} | |
| 747 | > | |
| 743 | 748 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> |
| 744 | 749 | <ThreadNavigationSidebar |
| 745 | 750 | width={layout.listPaneWidth} |
apps/mobile/src/features/pull-requests/PullRequestListRow.tsx+147-105| ... | ... | @@ -6,6 +6,11 @@ import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 6 | 6 | import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; |
| 7 | 7 | import { cn } from "../../lib/cn"; |
| 8 | 8 | import { relativeTime } from "../../lib/time"; |
| 9 | import { | |
| 10 | selectedThreadRowColors, | |
| 11 | THREAD_LIST_V2_SIDEBAR_ROW_STYLE, | |
| 12 | THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE, | |
| 13 | } from "../threads/thread-list-v2-row-appearance"; | |
| 9 | 14 | import { PullRequestAvatar, PullRequestDiffStats } from "./PullRequestPresentation"; |
| 10 | 15 | import { |
| 11 | 16 | pullRequestLabelColor, |
| ... | ... | @@ -20,11 +25,27 @@ export const PullRequestListRow = memo( |
| 20 | 25 | readonly item: EnvironmentPullRequestListEntry; |
| 21 | 26 | readonly onSelect: (item: EnvironmentPullRequestListEntry) => void; |
| 22 | 27 | readonly selected: boolean; |
| 28 | readonly sidebar: boolean; | |
| 23 | 29 | readonly busy: boolean; |
| 24 | 30 | readonly getActions: (item: EnvironmentPullRequestListEntry) => ControlPillMenuProps["actions"]; |
| 25 | 31 | readonly onAction: (item: EnvironmentPullRequestListEntry, action: string) => void; |
| 26 | 32 | }) { |
| 27 | 33 | const { item } = props; |
| 34 | const foreground = props.selected | |
| 35 | ? selectedThreadRowColors.foregroundClassName | |
| 36 | : props.sidebar | |
| 37 | ? "text-drawer-foreground" | |
| 38 | : "text-foreground"; | |
| 39 | const mutedForeground = props.selected | |
| 40 | ? selectedThreadRowColors.mutedForegroundClassName | |
| 41 | : props.sidebar | |
| 42 | ? "text-drawer-foreground-muted" | |
| 43 | : "text-foreground-muted"; | |
| 44 | const tertiaryForeground = props.selected | |
| 45 | ? selectedThreadRowColors.mutedForegroundClassName | |
| 46 | : props.sidebar | |
| 47 | ? "text-drawer-foreground-muted" | |
| 48 | : "text-foreground-tertiary"; | |
| 28 | 49 | const status = pullRequestStatus(item); |
| 29 | 50 | const review = pullRequestListReviewStatus(item); |
| 30 | 51 | const checkTint = |
| ... | ... | @@ -34,123 +55,144 @@ export const PullRequestListRow = memo( |
| 34 | 55 | ? "accent-danger-foreground" |
| 35 | 56 | : "accent-warning-foreground"; |
| 36 | 57 | return ( |
| 37 | <ControlPillMenu | |
| 38 | shouldOpenOnLongPress | |
| 39 | title={`#${item.number} · ${item.repository}`} | |
| 40 | actions={props.getActions(item)} | |
| 41 | onPressAction={({ nativeEvent }) => props.onAction(item, nativeEvent.event)} | |
| 42 | > | |
| 43 | <Pressable | |
| 44 | accessibilityRole="button" | |
| 45 | accessibilityState={{ selected: props.selected, busy: props.busy }} | |
| 46 | accessibilityLabel={`Pull request ${item.number}, ${item.title}, ${status.label}, by ${item.author?.login ?? "unknown author"}${review ? `, ${review.accessibleLabel}` : ""}${item.checksState ? `, checks ${item.checksState}` : ""}${item.stack ? `, stack ${item.stack.position} of ${item.stack.size}` : ""}${item.labels.length ? `, ${item.labels.map((label) => label.name).join(", ")}` : ""}, ${item.additions} additions, ${item.deletions} deletions`} | |
| 47 | accessibilityHint="Double tap to open. Touch and hold for quick actions." | |
| 48 | onPress={() => props.onSelect(item)} | |
| 49 | disabled={props.busy} | |
| 50 | className={cn( | |
| 51 | "flex-row gap-3 px-4 active:bg-row-hover", | |
| 52 | props.selected && "bg-thread-selected", | |
| 53 | props.busy && "opacity-50", | |
| 54 | )} | |
| 58 | <View style={props.sidebar ? THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE : undefined}> | |
| 59 | <ControlPillMenu | |
| 60 | shouldOpenOnLongPress | |
| 61 | title={`#${item.number} · ${item.repository}`} | |
| 62 | actions={props.getActions(item)} | |
| 63 | onPressAction={({ nativeEvent }) => props.onAction(item, nativeEvent.event)} | |
| 55 | 64 | > |
| 56 | <View className="pt-2.5"> | |
| 57 | <SymbolView name={status.icon} size={20} tintColorClassName={status.tint} /> | |
| 58 | </View> | |
| 59 | <View className="min-w-0 flex-1 gap-1 border-b border-border-subtle py-2.5"> | |
| 60 | <View className="flex-row items-center gap-1.5"> | |
| 61 | <PullRequestAvatar actor={item.author} size={16} /> | |
| 62 | <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}> | |
| 63 | {item.repository} <Text className="text-foreground-tertiary">#{item.number}</Text> | |
| 64 | </Text> | |
| 65 | {item.stack ? ( | |
| 66 | <View className="flex-row items-center gap-1"> | |
| 67 | <SymbolView | |
| 68 | name="square.3.layers.3d" | |
| 69 | size={12} | |
| 70 | tintColorClassName="accent-icon-muted" | |
| 71 | /> | |
| 72 | <Text className="text-2xs tabular-nums text-foreground-muted"> | |
| 73 | {item.stack.position}/{item.stack.size} | |
| 74 | </Text> | |
| 75 | </View> | |
| 76 | ) : null} | |
| 77 | <Text className="text-2xs tabular-nums text-foreground-tertiary"> | |
| 78 | {relativeTime(item.updatedAt)} | |
| 79 | </Text> | |
| 65 | <Pressable | |
| 66 | accessibilityRole="button" | |
| 67 | accessibilityState={{ selected: props.selected, busy: props.busy }} | |
| 68 | accessibilityLabel={`Pull request ${item.number}, ${item.title}, ${status.label}, by ${item.author?.login ?? "unknown author"}${review ? `, ${review.accessibleLabel}` : ""}${item.checksState ? `, checks ${item.checksState}` : ""}${item.stack ? `, stack ${item.stack.position} of ${item.stack.size}` : ""}${item.labels.length ? `, ${item.labels.map((label) => label.name).join(", ")}` : ""}, ${item.additions} additions, ${item.deletions} deletions`} | |
| 69 | accessibilityHint="Double tap to open. Touch and hold for quick actions." | |
| 70 | onPress={() => props.onSelect(item)} | |
| 71 | disabled={props.busy} | |
| 72 | style={props.sidebar ? THREAD_LIST_V2_SIDEBAR_ROW_STYLE : undefined} | |
| 73 | className={cn( | |
| 74 | "flex-row gap-3 overflow-hidden", | |
| 75 | !props.sidebar && "px-4 active:bg-row-hover", | |
| 76 | props.sidebar && "bg-drawer", | |
| 77 | props.sidebar && !props.selected && "active:bg-thread-hover", | |
| 78 | props.selected && "bg-thread-selected", | |
| 79 | props.busy && "opacity-50", | |
| 80 | )} | |
| 81 | > | |
| 82 | <View className={props.sidebar ? "pt-0.5" : "pt-2.5"}> | |
| 83 | <SymbolView name={status.icon} size={20} tintColorClassName={status.tint} /> | |
| 80 | 84 | </View> |
| 81 | <Text | |
| 82 | className="text-base leading-5 font-t3-medium text-foreground" | |
| 83 | numberOfLines={2} | |
| 84 | ellipsizeMode="tail" | |
| 85 | <View | |
| 86 | className={cn( | |
| 87 | "min-w-0 flex-1 gap-1", | |
| 88 | !props.sidebar && "border-b border-border-subtle py-2.5", | |
| 89 | )} | |
| 85 | 90 | > |
| 86 | {item.title} | |
| 87 | </Text> | |
| 88 | <View className="flex-row items-center gap-1.5"> | |
| 89 | <View className="min-w-0 flex-1 flex-row items-center gap-1"> | |
| 90 | {item.labels.slice(0, 2).map((label) => { | |
| 91 | const color = pullRequestLabelColor(label.color); | |
| 92 | return ( | |
| 93 | <View | |
| 94 | key={label.name} | |
| 95 | className="min-w-0 shrink flex-row items-center gap-1 rounded-md bg-subtle px-1.5 py-0.5" | |
| 96 | style={{ maxWidth: 160, ...(color ? { backgroundColor: `${color}20` } : {}) }} | |
| 97 | > | |
| 98 | {color ? ( | |
| 99 | <View | |
| 100 | style={{ width: 5, height: 5, borderRadius: 3, backgroundColor: color }} | |
| 101 | /> | |
| 102 | ) : null} | |
| 103 | <Text | |
| 104 | className="shrink text-2xs font-t3-medium text-foreground-muted" | |
| 105 | numberOfLines={1} | |
| 106 | > | |
| 107 | {label.name} | |
| 108 | </Text> | |
| 109 | </View> | |
| 110 | ); | |
| 111 | })} | |
| 112 | {item.labels.length > 2 ? ( | |
| 113 | <Text className="shrink-0 text-2xs text-foreground-tertiary"> | |
| 114 | +{item.labels.length - 2} | |
| 115 | </Text> | |
| 91 | <View className="flex-row items-center gap-1.5"> | |
| 92 | <PullRequestAvatar actor={item.author} size={16} /> | |
| 93 | <Text className={cn("min-w-0 flex-1 text-xs", mutedForeground)} numberOfLines={1}> | |
| 94 | {item.repository} <Text className={tertiaryForeground}>#{item.number}</Text> | |
| 95 | </Text> | |
| 96 | {item.stack ? ( | |
| 97 | <View className="flex-row items-center gap-1"> | |
| 98 | <SymbolView | |
| 99 | name="square.3.layers.3d" | |
| 100 | size={12} | |
| 101 | tintColorClassName={ | |
| 102 | props.selected | |
| 103 | ? selectedThreadRowColors.mutedIconTintClassName | |
| 104 | : props.sidebar | |
| 105 | ? "accent-drawer-foreground-muted" | |
| 106 | : "accent-foreground-muted" | |
| 107 | } | |
| 108 | /> | |
| 109 | <Text className={cn("text-2xs tabular-nums", mutedForeground)}> | |
| 110 | {item.stack.position}/{item.stack.size} | |
| 111 | </Text> | |
| 112 | </View> | |
| 116 | 113 | ) : null} |
| 114 | <Text className={cn("text-2xs tabular-nums", tertiaryForeground)}> | |
| 115 | {relativeTime(item.updatedAt)} | |
| 116 | </Text> | |
| 117 | 117 | </View> |
| 118 | {item.checksState ? ( | |
| 119 | <View className="shrink-0" accessibilityLabel={`Checks ${item.checksState}`}> | |
| 120 | <SymbolView | |
| 121 | name={ | |
| 122 | item.checksState === "passing" | |
| 123 | ? "checkmark.circle" | |
| 124 | : item.checksState === "failing" | |
| 125 | ? "xmark.circle.fill" | |
| 126 | : "clock" | |
| 127 | } | |
| 128 | size={13} | |
| 129 | tintColorClassName={checkTint} | |
| 130 | /> | |
| 131 | </View> | |
| 132 | ) : null} | |
| 133 | {review ? ( | |
| 134 | <View className="shrink-0" accessibilityLabel={review.accessibleLabel}> | |
| 135 | <SymbolView name={review.icon} size={13} tintColorClassName={review.tint} /> | |
| 118 | <Text | |
| 119 | className={cn("text-base leading-5 font-t3-medium", foreground)} | |
| 120 | numberOfLines={2} | |
| 121 | ellipsizeMode="tail" | |
| 122 | > | |
| 123 | {item.title} | |
| 124 | </Text> | |
| 125 | <View className="flex-row items-center gap-1.5"> | |
| 126 | <View className="min-w-0 flex-1 flex-row items-center gap-1"> | |
| 127 | {item.labels.slice(0, 2).map((label) => { | |
| 128 | const color = pullRequestLabelColor(label.color); | |
| 129 | return ( | |
| 130 | <View | |
| 131 | key={label.name} | |
| 132 | className="min-w-0 shrink flex-row items-center gap-1 rounded-md bg-subtle px-1.5 py-0.5" | |
| 133 | style={{ | |
| 134 | maxWidth: 160, | |
| 135 | ...(color ? { backgroundColor: `${color}20` } : {}), | |
| 136 | }} | |
| 137 | > | |
| 138 | {color ? ( | |
| 139 | <View | |
| 140 | style={{ width: 5, height: 5, borderRadius: 3, backgroundColor: color }} | |
| 141 | /> | |
| 142 | ) : null} | |
| 143 | <Text | |
| 144 | className={cn("shrink text-2xs font-t3-medium", mutedForeground)} | |
| 145 | numberOfLines={1} | |
| 146 | > | |
| 147 | {label.name} | |
| 148 | </Text> | |
| 149 | </View> | |
| 150 | ); | |
| 151 | })} | |
| 152 | {item.labels.length > 2 ? ( | |
| 153 | <Text className={cn("shrink-0 text-2xs", tertiaryForeground)}> | |
| 154 | +{item.labels.length - 2} | |
| 155 | </Text> | |
| 156 | ) : null} | |
| 136 | 157 | </View> |
| 137 | ) : null} | |
| 138 | {item.additions || item.deletions ? ( | |
| 139 | <PullRequestDiffStats | |
| 140 | additions={item.additions} | |
| 141 | deletions={item.deletions} | |
| 142 | compact | |
| 143 | /> | |
| 144 | ) : null} | |
| 158 | {item.checksState ? ( | |
| 159 | <View className="shrink-0" accessibilityLabel={`Checks ${item.checksState}`}> | |
| 160 | <SymbolView | |
| 161 | name={ | |
| 162 | item.checksState === "passing" | |
| 163 | ? "checkmark.circle" | |
| 164 | : item.checksState === "failing" | |
| 165 | ? "xmark.circle.fill" | |
| 166 | : "clock" | |
| 167 | } | |
| 168 | size={13} | |
| 169 | tintColorClassName={checkTint} | |
| 170 | /> | |
| 171 | </View> | |
| 172 | ) : null} | |
| 173 | {review ? ( | |
| 174 | <View className="shrink-0" accessibilityLabel={review.accessibleLabel}> | |
| 175 | <SymbolView name={review.icon} size={13} tintColorClassName={review.tint} /> | |
| 176 | </View> | |
| 177 | ) : null} | |
| 178 | {item.additions || item.deletions ? ( | |
| 179 | <PullRequestDiffStats | |
| 180 | additions={item.additions} | |
| 181 | deletions={item.deletions} | |
| 182 | compact | |
| 183 | /> | |
| 184 | ) : null} | |
| 185 | </View> | |
| 145 | 186 | </View> |
| 146 | </View> | |
| 147 | </Pressable> | |
| 148 | </ControlPillMenu> | |
| 187 | </Pressable> | |
| 188 | </ControlPillMenu> | |
| 189 | </View> | |
| 149 | 190 | ); |
| 150 | 191 | }, |
| 151 | 192 | (previous, next) => |
| 152 | 193 | pullRequestListRowsAreEqual(previous.item, next.item) && |
| 153 | 194 | previous.selected === next.selected && |
| 195 | previous.sidebar === next.sidebar && | |
| 154 | 196 | previous.busy === next.busy && |
| 155 | 197 | previous.onSelect === next.onSelect && |
| 156 | 198 | previous.onAction === next.onAction && |
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+12-5| ... | ... | @@ -53,7 +53,12 @@ function PullRequestScrollView(props: ScrollViewProps) { |
| 53 | 53 | ); |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | export function PullRequestsScreen() { | |
| 56 | export function PullRequestsScreen( | |
| 57 | props: { | |
| 58 | readonly sidebar?: boolean; | |
| 59 | readonly selectedPullRequestKey?: string | null; | |
| 60 | } = {}, | |
| 61 | ) { | |
| 57 | 62 | const navigation = useNavigation(); |
| 58 | 63 | const primaryColumn = use(NativePrimaryColumnContext); |
| 59 | 64 | const workspace = useWorkspaceTabs()!; |
| ... | ... | @@ -135,7 +140,8 @@ export function PullRequestsScreen() { |
| 135 | 140 | connectedTargets.map((target) => target.environmentId), |
| 136 | 141 | result.updateEntry, |
| 137 | 142 | ); |
| 138 | const selectedKey = primaryColumn?.selectedPullRequestKey; | |
| 143 | const sidebar = props.sidebar === true || primaryColumn !== null; | |
| 144 | const selectedKey = props.selectedPullRequestKey ?? primaryColumn?.selectedPullRequestKey; | |
| 139 | 145 | const entries = useMemo( |
| 140 | 146 | () => |
| 141 | 147 | filterPullRequestList( |
| ... | ... | @@ -179,9 +185,10 @@ export function PullRequestsScreen() { |
| 179 | 185 | onAction={select} |
| 180 | 186 | busy={busyKey === pullRequestKey(item)} |
| 181 | 187 | selected={selectedKey === pullRequestKey(item)} |
| 188 | sidebar={sidebar} | |
| 182 | 189 | /> |
| 183 | 190 | ), |
| 184 | [selectPullRequest, selectedKey, actions, select, busyKey], | |
| 191 | [selectPullRequest, selectedKey, actions, select, busyKey, sidebar], | |
| 185 | 192 | ); |
| 186 | 193 | const previousSearchMode = useRef(tab.searchMode); |
| 187 | 194 | useEffect(() => { |
| ... | ... | @@ -199,11 +206,11 @@ export function PullRequestsScreen() { |
| 199 | 206 | : -1; |
| 200 | 207 | |
| 201 | 208 | return ( |
| 202 | <View className={primaryColumn ? "flex-1 bg-drawer" : "flex-1 bg-screen"}> | |
| 209 | <View className={sidebar ? "flex-1 bg-drawer" : "flex-1 bg-screen"}> | |
| 203 | 210 | <LegendList |
| 204 | 211 | ref={listRef} |
| 205 | 212 | data={entries} |
| 206 | extraData={`${primaryColumn?.selectedPullRequestKey}:${busyKey}`} | |
| 213 | extraData={`${selectedKey}:${busyKey}`} | |
| 207 | 214 | keyExtractor={pullRequestKey} |
| 208 | 215 | itemsAreEqual={pullRequestListRowsAreEqual} |
| 209 | 216 | // Default-size rows measure about 78pt with one title line and 98pt with two. |
apps/mobile/src/features/pull-requests/pullRequestSelection.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import type { NavigationState } from "@react-navigation/native"; | |
| 3 | import { selectedPullRequestKey } from "./pullRequestSelection"; | |
| 4 | ||
| 5 | const params = { | |
| 6 | environmentId: "environment-one", | |
| 7 | projectId: "project-one", | |
| 8 | host: "GitHub.COM", | |
| 9 | repository: "Org/Repo", | |
| 10 | number: 42, | |
| 11 | }; | |
| 12 | const home = { key: "home", name: "Home" }; | |
| 13 | const pullRequest = { key: "pr", name: "PullRequest", params }; | |
| 14 | function selection(routes: NavigationState["routes"], index = routes.length - 1) { | |
| 15 | return selectedPullRequestKey({ routes, index }); | |
| 16 | } | |
| 17 | ||
| 18 | describe("displayed pull request selection", () => { | |
| 19 | it("normalizes repository and host while retaining the environment scope", () => { | |
| 20 | expect(selection([home, pullRequest])).toBe("environment-one:github.com:org/repo:42"); | |
| 21 | expect( | |
| 22 | selection([ | |
| 23 | home, | |
| 24 | { ...pullRequest, params: { ...params, environmentId: "environment-two" } }, | |
| 25 | ]), | |
| 26 | ).toBe("environment-two:github.com:org/repo:42"); | |
| 27 | }); | |
| 28 | ||
| 29 | it.each([ | |
| 30 | "PullRequestFile", | |
| 31 | "PullRequestFiles", | |
| 32 | "PullRequestGuideStop", | |
| 33 | "PullRequestDiscussion", | |
| 34 | "PullRequestReviewers", | |
| 35 | "PullRequestReview", | |
| 36 | "PullRequestInlineComment", | |
| 37 | ])("keeps the owning selection on %s", (name) => { | |
| 38 | expect(selection([home, pullRequest, { key: name, name, params }])).toBe( | |
| 39 | "environment-one:github.com:org/repo:42", | |
| 40 | ); | |
| 41 | }); | |
| 42 | ||
| 43 | it("uses the most recently opened PR, including a different hosting service", () => { | |
| 44 | expect( | |
| 45 | selection([ | |
| 46 | home, | |
| 47 | pullRequest, | |
| 48 | { | |
| 49 | key: "other-pr", | |
| 50 | name: "PullRequest", | |
| 51 | params: { ...params, host: "github.enterprise", repository: "Other/Repo", number: 7 }, | |
| 52 | }, | |
| 53 | ]), | |
| 54 | ).toBe("environment-one:github.enterprise:other/repo:7"); | |
| 55 | }); | |
| 56 | ||
| 57 | it.each(["Home", "Thread", "ThreadFiles"])("clears an older selection on %s", (name) => { | |
| 58 | expect(selection([home, pullRequest, { key: "current", name }])).toBeNull(); | |
| 59 | }); | |
| 60 | ||
| 61 | it("ignores inactive routes after the navigation index", () => { | |
| 62 | expect(selection([home, pullRequest], 0)).toBeNull(); | |
| 63 | }); | |
| 64 | ||
| 65 | it("retains the owning PR behind an unrelated overlay", () => { | |
| 66 | expect(selection([home, pullRequest, { key: "settings", name: "SettingsSheet" }])).toBe( | |
| 67 | "environment-one:github.com:org/repo:42", | |
| 68 | ); | |
| 69 | }); | |
| 70 | ||
| 71 | it("does not highlight an older PR when the current PR lacks its hosting identity", () => { | |
| 72 | expect( | |
| 73 | selection([ | |
| 74 | home, | |
| 75 | pullRequest, | |
| 76 | { key: "unresolved", name: "PullRequest", params: { environmentId: "environment-one" } }, | |
| 77 | ]), | |
| 78 | ).toBeNull(); | |
| 79 | }); | |
| 80 | }); |
apps/mobile/src/features/pull-requests/pullRequestSelection.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | import type { NavigationState } from "@react-navigation/native"; | |
| 2 | import { pullRequestListKey } from "./pullRequestList"; | |
| 3 | ||
| 4 | /** Follow the displayed PR through its detail routes and sheets, not an older stack entry. */ | |
| 5 | export function selectedPullRequestKey(state: Pick<NavigationState, "routes" | "index">) { | |
| 6 | for (let index = state.index; index >= 0; index--) { | |
| 7 | const route = state.routes[index]; | |
| 8 | if (!route) continue; | |
| 9 | if (route.name === "Home" || route.name.startsWith("Thread")) return null; | |
| 10 | if (!route.name.startsWith("PullRequest")) continue; | |
| 11 | const params = route.params; | |
| 12 | if ( | |
| 13 | params && | |
| 14 | "environmentId" in params && | |
| 15 | "host" in params && | |
| 16 | "repository" in params && | |
| 17 | "number" in params && | |
| 18 | typeof params.environmentId === "string" && | |
| 19 | typeof params.host === "string" && | |
| 20 | typeof params.repository === "string" && | |
| 21 | typeof params.number === "number" | |
| 22 | ) { | |
| 23 | return `${params.environmentId}:${pullRequestListKey({ | |
| 24 | host: params.host, | |
| 25 | repository: params.repository, | |
| 26 | number: params.number, | |
| 27 | })}`; | |
| 28 | } | |
| 29 | if (route.name === "PullRequest") return null; | |
| 30 | } | |
| 31 | return null; | |
| 32 | } |
apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts+14-8| ... | ... | @@ -4,6 +4,17 @@ import type { MobileThemeVariables } from "../../lib/mobileTheme"; |
| 4 | 4 | export const THREAD_LIST_V2_MONO_FONT = "monospace"; |
| 5 | 5 | export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-3 py-2.5"; |
| 6 | 6 | export const THREAD_LIST_V2_ROW_DIVIDERS = false; |
| 7 | export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { | |
| 8 | borderRadius: 20, | |
| 9 | paddingHorizontal: 12, | |
| 10 | paddingVertical: 10, | |
| 11 | } satisfies ViewStyle; | |
| 12 | export const THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE = { | |
| 13 | borderRadius: 20, | |
| 14 | overflow: "hidden", | |
| 15 | marginHorizontal: 8, | |
| 16 | marginVertical: 2, | |
| 17 | } satisfies ViewStyle; | |
| 7 | 18 | |
| 8 | 19 | export const selectedThreadRowColors = { |
| 9 | 20 | foregroundClassName: "text-thread-selected-foreground", |
| ... | ... | @@ -25,14 +36,9 @@ export function getThreadListV2RowAppearance( |
| 25 | 36 | const backgroundColor = theme[sidebarPane ? "--color-drawer" : "--color-screen"]; |
| 26 | 37 | const style: ViewStyle = { |
| 27 | 38 | backgroundColor: selected ? selectedBackgroundColor : backgroundColor, |
| 28 | borderRadius: 20, | |
| 29 | }; | |
| 30 | const swipeContainerStyle: ViewStyle = { | |
| 31 | borderRadius: 20, | |
| 32 | overflow: "hidden", | |
| 33 | marginHorizontal: 8, | |
| 34 | marginVertical: 2, | |
| 39 | borderRadius: THREAD_LIST_V2_SIDEBAR_ROW_STYLE.borderRadius, | |
| 35 | 40 | }; |
| 41 | const swipeContainerStyle = THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE; | |
| 36 | 42 | |
| 37 | 43 | return { |
| 38 | 44 | className: undefined, |
| ... | ... | @@ -52,7 +58,7 @@ export function getThreadListV2RowAppearance( |
| 52 | 58 | ? "accent-drawer-foreground-muted" |
| 53 | 59 | : "accent-foreground-tertiary", |
| 54 | 60 | style, |
| 55 | cardStyle: sidebarPane ? { ...style, paddingHorizontal: 12, paddingVertical: 10 } : style, | |
| 61 | cardStyle: sidebarPane ? { ...style, ...THREAD_LIST_V2_SIDEBAR_ROW_STYLE } : style, | |
| 56 | 62 | swipeContainerStyle, |
| 57 | 63 | swipeBackgroundColor: backgroundColor, |
| 58 | 64 | providerIconSurfaceColor: selected ? selectedBackgroundColor : backgroundColor, |
apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts+12-3| ... | ... | @@ -4,6 +4,15 @@ import type { MobileThemeVariables } from "../../lib/mobileTheme"; |
| 4 | 4 | export const THREAD_LIST_V2_MONO_FONT = "Menlo"; |
| 5 | 5 | export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-5 py-2.5"; |
| 6 | 6 | export const THREAD_LIST_V2_ROW_DIVIDERS = true; |
| 7 | export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { | |
| 8 | borderRadius: 12, | |
| 9 | paddingHorizontal: 12, | |
| 10 | paddingVertical: 10, | |
| 11 | } satisfies ViewStyle; | |
| 12 | export const THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE = { | |
| 13 | borderRadius: 12, | |
| 14 | overflow: "hidden", | |
| 15 | } satisfies ViewStyle; | |
| 7 | 16 | |
| 8 | 17 | export const selectedThreadRowColors = { |
| 9 | 18 | foregroundClassName: "text-thread-selected-foreground", |
| ... | ... | @@ -25,11 +34,11 @@ export function getThreadListV2RowAppearance( |
| 25 | 34 | const style: ViewStyle | undefined = sidebarPane |
| 26 | 35 | ? { |
| 27 | 36 | backgroundColor: selected ? selectedBackgroundColor : theme["--color-drawer"], |
| 28 | borderRadius: 12, | |
| 37 | borderRadius: THREAD_LIST_V2_SIDEBAR_ROW_STYLE.borderRadius, | |
| 29 | 38 | } |
| 30 | 39 | : undefined; |
| 31 | 40 | const swipeContainerStyle: ViewStyle | undefined = sidebarPane |
| 32 | ? { borderRadius: 12, overflow: "hidden" } | |
| 41 | ? THREAD_LIST_V2_SIDEBAR_ROW_CONTAINER_STYLE | |
| 33 | 42 | : undefined; |
| 34 | 43 | |
| 35 | 44 | return { |
| ... | ... | @@ -50,7 +59,7 @@ export function getThreadListV2RowAppearance( |
| 50 | 59 | ? "accent-drawer-foreground-muted" |
| 51 | 60 | : "accent-foreground-tertiary", |
| 52 | 61 | style, |
| 53 | cardStyle: sidebarPane ? { ...style, paddingHorizontal: 12, paddingVertical: 10 } : undefined, | |
| 62 | cardStyle: sidebarPane ? { ...style, ...THREAD_LIST_V2_SIDEBAR_ROW_STYLE } : undefined, | |
| 54 | 63 | swipeContainerStyle, |
| 55 | 64 | swipeBackgroundColor: theme[sidebarPane ? "--color-drawer" : "--color-screen"], |
| 56 | 65 | // Provider badges blend into the surface beneath them. |
apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx+2-1| ... | ... | @@ -8,6 +8,7 @@ import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; |
| 8 | 8 | export function AndroidWorkspaceSidebar(props: { |
| 9 | 9 | readonly children: ReactNode; |
| 10 | 10 | readonly onRequestVisibility: () => void; |
| 11 | readonly selectedPullRequestKey: string | null; | |
| 11 | 12 | }) { |
| 12 | 13 | const workspace = useWorkspaceTabs()!; |
| 13 | 14 | const context = useMemo(() => ({ ...workspace, bottomClearance: 16 }), [workspace]); |
| ... | ... | @@ -18,7 +19,7 @@ export function AndroidWorkspaceSidebar(props: { |
| 18 | 19 | <View className="flex-1 bg-header"> |
| 19 | 20 | <MaterialPullRequestToolbar onRequestVisibility={props.onRequestVisibility} /> |
| 20 | 21 | <MaterialScreenContent insetHorizontal> |
| 21 | <PullRequestsScreen /> | |
| 22 | <PullRequestsScreen sidebar selectedPullRequestKey={props.selectedPullRequestKey} /> | |
| 22 | 23 | </MaterialScreenContent> |
| 23 | 24 | </View> |
| 24 | 25 | </WorkspaceTabsContext> |
apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.tsx+1| ... | ... | @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; |
| 3 | 3 | export function AndroidWorkspaceSidebar(props: { |
| 4 | 4 | readonly children: ReactNode; |
| 5 | 5 | readonly onRequestVisibility: () => void; |
| 6 | readonly selectedPullRequestKey: string | null; | |
| 6 | 7 | }) { |
| 7 | 8 | return props.children; |
| 8 | 9 | } |