| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 3 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 4 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 5 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 6 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 7 | import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types"; |
| 8 | import type { ReviewSectionItem } from "./reviewModel"; |
| 9 | import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation"; |
| 10 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; |
| 11 | import { |
| 12 | memo, |
| 13 | type Ref, |
| 14 | type ReactElement, |
| 15 | useCallback, |
| 16 | useEffect, |
| 17 | useImperativeHandle, |
| 18 | useMemo, |
| 19 | useRef, |
| 20 | useState, |
| 21 | } from "react"; |
| 22 | import { |
| 23 | FlatList, |
| 24 | Platform, |
| 25 | Pressable, |
| 26 | RefreshControl, |
| 27 | ScrollView, |
| 28 | type NativeSyntheticEvent, |
| 29 | StyleSheet, |
| 30 | View, |
| 31 | } from "react-native"; |
| 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 33 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; |
| 34 | |
| 35 | import { AppText as Text } from "../../components/AppText"; |
| 36 | import { SymbolView } from "../../components/AppSymbol"; |
| 37 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 38 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| 39 | import { cn } from "../../lib/cn"; |
| 40 | import { environmentCatalog } from "../../connection/catalog"; |
| 41 | import { useEnvironmentPresentation } from "../../state/presentation"; |
| 42 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 43 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 44 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 45 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; |
| 46 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 47 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 48 | import { |
| 49 | useAdaptiveWorkspaceLayout, |
| 50 | useAdaptiveWorkspacePaneRole, |
| 51 | useRegisterWorkspaceInspector, |
| 52 | } from "../layout/AdaptiveWorkspaceLayout"; |
| 53 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 54 | import { useReviewCacheForThread } from "./reviewState"; |
| 55 | import { |
| 56 | isNativeReviewDiffDrawEvent, |
| 57 | type NativeReviewDiffViewHandle, |
| 58 | resolveNativeReviewDiffView, |
| 59 | } from "../diffs/nativeReviewDiffSurface"; |
| 60 | import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter"; |
| 61 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 62 | import { useReviewDiffData } from "./useReviewDiffData"; |
| 63 | import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming"; |
| 64 | import { useReviewFileVisibility } from "./reviewFileVisibility"; |
| 65 | import { useReviewSections } from "./useReviewSections"; |
| 66 | import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; |
| 67 | import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; |
| 68 | import { resolveReviewAvailability } from "./reviewAvailability"; |
| 69 | import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; |
| 70 | import { buildReviewSectionMenu } from "./review-section-menu"; |
| 71 | import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal"; |
| 72 | |
| 73 | function ReviewHeader( |
| 74 | props: Parameters<typeof useReviewHeaderPresentation>[0] & { |
| 75 | readonly iconColor: string; |
| 76 | readonly sectionMenu: ReturnType<typeof buildReviewSectionMenu>; |
| 77 | readonly showSectionToolbar: boolean; |
| 78 | readonly showChangedFilesToggle: boolean; |
| 79 | readonly onSelectSection: (sectionId: string) => void; |
| 80 | readonly onReturnToThread: () => void; |
| 81 | }, |
| 82 | ) { |
| 83 | const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 84 | const presentation = useReviewHeaderPresentation(props); |
| 85 | const sectionAction = ( |
| 86 | section: ReviewSectionItem | null, |
| 87 | title: string, |
| 88 | ): ScreenHeaderMenuItem => ({ |
| 89 | id: section ? `section:${section.id}` : `unavailable:${title}`, |
| 90 | title, |
| 91 | disabled: section === null, |
| 92 | selected: section !== null && section.id === props.selectedSection?.id, |
| 93 | onPress: () => { |
| 94 | if (section) props.onSelectSection(section.id); |
| 95 | }, |
| 96 | }); |
| 97 | return ( |
| 98 | <ScreenHeader |
| 99 | title={presentation.title} |
| 100 | subtitle={presentation.subtitle} |
| 101 | onBack={props.onReturnToThread} |
| 102 | hideBottomBorder |
| 103 | options={{ headerTintColor: props.iconColor, headerTitle: props.title }} |
| 104 | backInSplitView={{ accessibilityLabel: "Back to chat", icon: "chevron.left" }} |
| 105 | actions={ |
| 106 | props.showChangedFilesToggle |
| 107 | ? [ |
| 108 | { |
| 109 | accessibilityLabel: panes.auxiliaryPaneVisible |
| 110 | ? "Hide changed files" |
| 111 | : "Show changed files", |
| 112 | icon: "sidebar.right", |
| 113 | selected: panes.auxiliaryPaneVisible, |
| 114 | onPress: toggleAuxiliaryPane, |
| 115 | }, |
| 116 | ] |
| 117 | : undefined |
| 118 | } |
| 119 | menus={[ |
| 120 | ...(presentation.gitMenu ? [presentation.gitMenu] : []), |
| 121 | ...(props.showSectionToolbar |
| 122 | ? [ |
| 123 | { |
| 124 | title: "Select diff", |
| 125 | icon: presentation.menuIcon, |
| 126 | items: [ |
| 127 | { |
| 128 | id: "sections", |
| 129 | inline: true, |
| 130 | items: [ |
| 131 | sectionAction(props.sectionMenu.branchChanges, "Changes"), |
| 132 | sectionAction(props.sectionMenu.workingTree, "Uncommitted"), |
| 133 | sectionAction(props.sectionMenu.latestTurn, "Latest turn"), |
| 134 | ], |
| 135 | }, |
| 136 | ...(props.sectionMenu.turns.length > 0 |
| 137 | ? [ |
| 138 | { |
| 139 | id: "turns", |
| 140 | title: "Turn", |
| 141 | items: props.sectionMenu.turns.map((section) => ({ |
| 142 | id: `section:${section.id}`, |
| 143 | title: section.title, |
| 144 | subtitle: section.subtitle ?? undefined, |
| 145 | selected: section.id === props.selectedSection?.id, |
| 146 | onPress: () => props.onSelectSection(section.id), |
| 147 | })), |
| 148 | }, |
| 149 | ] |
| 150 | : []), |
| 151 | ...(presentation.refreshAction ? [presentation.refreshAction] : []), |
| 152 | ], |
| 153 | }, |
| 154 | ] |
| 155 | : []), |
| 156 | ]} |
| 157 | /> |
| 158 | ); |
| 159 | } |
| 160 | |
| 161 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 162 | |
| 163 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { |
| 164 | return ( |
| 165 | <View |
| 166 | className={cn( |
| 167 | "bg-warning px-4 py-3", |
| 168 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-warning-border", |
| 169 | )} |
| 170 | > |
| 171 | <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text> |
| 172 | <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text> |
| 173 | </View> |
| 174 | ); |
| 175 | }); |
| 176 | |
| 177 | function ReviewSelectionActionBar(props: { |
| 178 | readonly bottomInset: number; |
| 179 | readonly title: string | null; |
| 180 | readonly onOpenComment: (() => void) | null; |
| 181 | readonly onClear: () => void; |
| 182 | }) { |
| 183 | if (!props.title) { |
| 184 | return null; |
| 185 | } |
| 186 | |
| 187 | const content = ( |
| 188 | <> |
| 189 | <SymbolView |
| 190 | name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"} |
| 191 | size={16} |
| 192 | tintColorClassName="accent-primary-foreground" |
| 193 | type="monochrome" |
| 194 | /> |
| 195 | <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text> |
| 196 | </> |
| 197 | ); |
| 198 | |
| 199 | return ( |
| 200 | <View |
| 201 | pointerEvents="box-none" |
| 202 | style={{ |
| 203 | position: "absolute", |
| 204 | left: 18, |
| 205 | right: 18, |
| 206 | bottom: Math.max(props.bottomInset, 10) + 18, |
| 207 | flexDirection: "row", |
| 208 | justifyContent: "center", |
| 209 | gap: 10, |
| 210 | }} |
| 211 | > |
| 212 | {props.onOpenComment ? ( |
| 213 | <Pressable |
| 214 | className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5" |
| 215 | onPress={props.onOpenComment} |
| 216 | > |
| 217 | {content} |
| 218 | </Pressable> |
| 219 | ) : ( |
| 220 | <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5"> |
| 221 | {content} |
| 222 | </View> |
| 223 | )} |
| 224 | |
| 225 | <Pressable |
| 226 | className="h-12 w-12 items-center justify-center rounded-full bg-primary" |
| 227 | onPress={props.onClear} |
| 228 | > |
| 229 | <SymbolView |
| 230 | name="xmark" |
| 231 | size={16} |
| 232 | tintColorClassName="accent-primary-foreground" |
| 233 | type="monochrome" |
| 234 | /> |
| 235 | </Pressable> |
| 236 | </View> |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | interface ReviewNavigatorFile { |
| 241 | readonly id: string; |
| 242 | readonly path: string; |
| 243 | readonly additions: number; |
| 244 | readonly deletions: number; |
| 245 | } |
| 246 | |
| 247 | const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { |
| 248 | readonly file: ReviewNavigatorFile; |
| 249 | readonly selected: boolean; |
| 250 | readonly onSelectFile: (fileId: string | null) => void; |
| 251 | }) { |
| 252 | const { file, selected, onSelectFile } = props; |
| 253 | // Tapping the selected file again returns to the all-files diff. |
| 254 | const handlePress = useCallback(() => { |
| 255 | onSelectFile(selected ? null : file.id); |
| 256 | }, [file.id, onSelectFile, selected]); |
| 257 | |
| 258 | return ( |
| 259 | <Pressable |
| 260 | accessibilityRole="button" |
| 261 | accessibilityState={{ selected }} |
| 262 | className={ |
| 263 | Platform.OS === "android" |
| 264 | ? cn( |
| 265 | "mt-1 min-h-12 justify-center rounded-[20px] px-3 py-2 active:bg-subtle", |
| 266 | selected && "bg-subtle-strong", |
| 267 | ) |
| 268 | : selected |
| 269 | ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" |
| 270 | : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle" |
| 271 | } |
| 272 | onPress={handlePress} |
| 273 | > |
| 274 | <Text |
| 275 | className={ |
| 276 | selected |
| 277 | ? "text-xs font-t3-bold text-foreground" |
| 278 | : "text-xs font-t3-medium text-foreground-secondary" |
| 279 | } |
| 280 | numberOfLines={2} |
| 281 | > |
| 282 | {file.path} |
| 283 | </Text> |
| 284 | <View className="mt-1 flex-row gap-2"> |
| 285 | <Text className="text-2xs font-t3-bold text-adaptive-emerald-700-300"> |
| 286 | +{formatDiffCount(file.additions)} |
| 287 | </Text> |
| 288 | <Text className="text-2xs font-t3-bold text-adaptive-rose-700-300"> |
| 289 | -{formatDiffCount(file.deletions)} |
| 290 | </Text> |
| 291 | </View> |
| 292 | </Pressable> |
| 293 | ); |
| 294 | }); |
| 295 | |
| 296 | interface ReviewFileNavigatorHandle { |
| 297 | readonly setVisibleFile: (fileId: string | null) => void; |
| 298 | } |
| 299 | |
| 300 | interface ReviewFileNavigatorProps { |
| 301 | readonly files: ReadonlyArray<ReviewNavigatorFile>; |
| 302 | readonly headerInset: number; |
| 303 | readonly sectionId: string | null; |
| 304 | readonly onSelectFile: (fileId: string | null) => void; |
| 305 | readonly ref?: Ref<ReviewFileNavigatorHandle>; |
| 306 | } |
| 307 | |
| 308 | function ReviewFileNavigator({ |
| 309 | files, |
| 310 | headerInset, |
| 311 | sectionId, |
| 312 | onSelectFile, |
| 313 | ref, |
| 314 | }: ReviewFileNavigatorProps) { |
| 315 | const insets = useSafeAreaInsets(); |
| 316 | const theme = useUniwindTheme(); |
| 317 | const sheetColor = theme["--color-sheet"]; |
| 318 | const foregroundColor = theme["--color-foreground"]; |
| 319 | const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 320 | const [fileSelection, setFileSelection] = useState<{ |
| 321 | readonly sectionId: string | null; |
| 322 | readonly fileId: string | null; |
| 323 | }>({ sectionId: null, fileId: null }); |
| 324 | const availableFileIds = useMemo(() => files.map((file) => file.id), [files]); |
| 325 | const selectedFileId = resolveSelectedReviewFileId({ |
| 326 | selection: fileSelection, |
| 327 | sectionId, |
| 328 | availableFileIds, |
| 329 | }); |
| 330 | |
| 331 | useImperativeHandle( |
| 332 | ref, |
| 333 | () => ({ |
| 334 | setVisibleFile: (fileId) => { |
| 335 | if (fileId !== null && !availableFileIds.includes(fileId)) { |
| 336 | return; |
| 337 | } |
| 338 | setFileSelection((current) => { |
| 339 | if (current.sectionId === sectionId && current.fileId === fileId) { |
| 340 | return current; |
| 341 | } |
| 342 | return { sectionId, fileId }; |
| 343 | }); |
| 344 | }, |
| 345 | }), |
| 346 | [availableFileIds, sectionId], |
| 347 | ); |
| 348 | |
| 349 | const handleSelectFile = useCallback( |
| 350 | (fileId: string | null) => { |
| 351 | setFileSelection({ sectionId, fileId }); |
| 352 | onSelectFile(fileId); |
| 353 | }, |
| 354 | [onSelectFile, sectionId], |
| 355 | ); |
| 356 | |
| 357 | const renderFile = useCallback( |
| 358 | ({ item }: { readonly item: ReviewNavigatorFile }) => ( |
| 359 | <ReviewFileNavigatorRow |
| 360 | file={item} |
| 361 | selected={selectedFileId === item.id} |
| 362 | onSelectFile={handleSelectFile} |
| 363 | /> |
| 364 | ), |
| 365 | [handleSelectFile, selectedFileId], |
| 366 | ); |
| 367 | |
| 368 | const fileList = ( |
| 369 | <FlatList |
| 370 | data={files} |
| 371 | extraData={selectedFileId} |
| 372 | keyExtractor={(file) => file.id} |
| 373 | contentContainerStyle={{ |
| 374 | paddingHorizontal: 8, |
| 375 | paddingBottom: 8, |
| 376 | // The nested native header is translucent; start the list below it so |
| 377 | // the scroll-edge effect can sample the content (same treatment as |
| 378 | // FileTreeBrowser in the Files pane). |
| 379 | paddingTop: Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT + 8 : 8, |
| 380 | }} |
| 381 | scrollIndicatorInsets={ |
| 382 | Platform.OS === "ios" ? { top: insets.top + IOS_NAV_BAR_HEIGHT } : undefined |
| 383 | } |
| 384 | renderItem={renderFile} |
| 385 | /> |
| 386 | ); |
| 387 | |
| 388 | if (Platform.OS === "ios") { |
| 389 | return ( |
| 390 | <View className="flex-1 border-l border-border bg-sheet"> |
| 391 | <ScreenStack style={{ flex: 1 }}> |
| 392 | <Screen |
| 393 | activityState={2} |
| 394 | enabled |
| 395 | isNativeStack |
| 396 | screenId="review-file-navigator-native" |
| 397 | scrollEdgeEffects={headerScrollEdgeEffects} |
| 398 | style={{ backgroundColor: sheetColor, flex: 1 }} |
| 399 | > |
| 400 | {fileList} |
| 401 | <ScreenStackHeaderConfig |
| 402 | backgroundColor="rgba(0,0,0,0)" |
| 403 | color={foregroundColor} |
| 404 | hideBackButton |
| 405 | hideShadow={false} |
| 406 | navigationItemStyle="editor" |
| 407 | subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`} |
| 408 | title="Changed files" |
| 409 | titleColor={foregroundColor} |
| 410 | titleFontSize={17} |
| 411 | titleFontWeight="700" |
| 412 | translucent |
| 413 | /> |
| 414 | </Screen> |
| 415 | </ScreenStack> |
| 416 | </View> |
| 417 | ); |
| 418 | } |
| 419 | |
| 420 | return ( |
| 421 | <View |
| 422 | className={ |
| 423 | Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet" |
| 424 | } |
| 425 | > |
| 426 | {Platform.OS === "android" ? ( |
| 427 | <AndroidScreenHeader |
| 428 | title="Changed files" |
| 429 | subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`} |
| 430 | hideBottomBorder |
| 431 | /> |
| 432 | ) : ( |
| 433 | <View className="border-b border-border" style={{ paddingTop: headerInset }}> |
| 434 | <View className="px-4 py-3"> |
| 435 | <Text className="text-sm font-t3-bold text-foreground">Changed files</Text> |
| 436 | <Text className="text-xs text-foreground-muted"> |
| 437 | {files.length} {files.length === 1 ? "file" : "files"} |
| 438 | </Text> |
| 439 | </View> |
| 440 | </View> |
| 441 | )} |
| 442 | <MaterialScreenContent insetHorizontal>{fileList}</MaterialScreenContent> |
| 443 | </View> |
| 444 | ); |
| 445 | } |
| 446 | |
| 447 | type ReviewSheetProps = StaticScreenProps<{ |
| 448 | readonly environmentId: EnvironmentId; |
| 449 | readonly threadId: ThreadId; |
| 450 | }>; |
| 451 | |
| 452 | export function ReviewSheet(props: ReviewSheetProps) { |
| 453 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); |
| 454 | useAdaptiveWorkspacePaneRole("inspector"); |
| 455 | const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 456 | const navigation = useNavigation(); |
| 457 | const insets = useSafeAreaInsets(); |
| 458 | const { themeAppearance: selectedTheme } = useAppearancePreferences(); |
| 459 | const headerIcon = String(useUniwindTheme()["--color-icon"]); |
| 460 | const { environmentId, threadId } = props.route.params; |
| 461 | const environment = useEnvironmentPresentation(environmentId); |
| 462 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 463 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; |
| 464 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); |
| 465 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); |
| 466 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 467 | const columnMetrics = useNativeColumnLayoutMetrics(); |
| 468 | const safeArea = columnMetrics?.safeArea ?? insets; |
| 469 | const topContentInset = Platform.OS === "ios" ? safeArea.top : 0; |
| 470 | const bottomContentInset = |
| 471 | Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18; |
| 472 | |
| 473 | useEffect(() => { |
| 474 | showAuxiliaryPane("inspector"); |
| 475 | }, [environmentId, showAuxiliaryPane, threadId]); |
| 476 | const { |
| 477 | error, |
| 478 | reviewSections, |
| 479 | selectedSection, |
| 480 | refreshSelectedSection, |
| 481 | selectSection, |
| 482 | isSelectedSectionPending, |
| 483 | diffPreviewRevision, |
| 484 | } = useReviewSections({ |
| 485 | enabled: isEnvironmentReady, |
| 486 | environmentId, |
| 487 | threadId, |
| 488 | reviewCache, |
| 489 | }); |
| 490 | useReviewDiffPrewarming({ |
| 491 | threadKey: reviewCache.threadKey, |
| 492 | sections: reviewSections, |
| 493 | selectedSectionId: selectedSection?.id ?? null, |
| 494 | }); |
| 495 | const { |
| 496 | headerDiffSummary, |
| 497 | nativeReviewDiffData, |
| 498 | parsedDiff, |
| 499 | pendingReviewCommentCount, |
| 500 | loadVisibleFile, |
| 501 | isPending: areFilePatchesPending, |
| 502 | } = useReviewDiffData({ |
| 503 | threadKey: reviewCache.threadKey, |
| 504 | environmentId, |
| 505 | cwd: selectedThreadCwd, |
| 506 | selectedSection, |
| 507 | revision: diffPreviewRevision, |
| 508 | draftMessage, |
| 509 | }); |
| 510 | // Resolution returns null while Expo registers the native view (or forever |
| 511 | // when the binary lacks it). Rendering a null component type crashes the |
| 512 | // app, so callers must fall back — ThreadFeed's ReviewCommentCard does the |
| 513 | // same check. |
| 514 | const NativeReviewDiffView = resolveNativeReviewDiffView(); |
| 515 | const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null); |
| 516 | const showcasedReviewDrawRef = useRef<string | null>(null); |
| 517 | // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). |
| 518 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); |
| 519 | const handlePullToRefresh = useCallback(async () => { |
| 520 | setIsPullRefreshing(true); |
| 521 | try { |
| 522 | await refreshSelectedSection(); |
| 523 | } finally { |
| 524 | setIsPullRefreshing(false); |
| 525 | } |
| 526 | }, [refreshSelectedSection]); |
| 527 | const reviewFileNavigatorRef = useRef<ReviewFileNavigatorHandle>(null); |
| 528 | const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; |
| 529 | const fileVisibility = useReviewFileVisibility({ |
| 530 | threadKey: reviewCache.threadKey, |
| 531 | sectionId: selectedSection?.id ?? null, |
| 532 | files: reviewFiles, |
| 533 | cachedExpandedFileIds: selectedSection?.id |
| 534 | ? reviewCache.expandedFileIdsBySection[selectedSection.id] |
| 535 | : undefined, |
| 536 | cachedViewedFileIds: selectedSection?.id |
| 537 | ? reviewCache.viewedFileIdsBySection[selectedSection.id] |
| 538 | : undefined, |
| 539 | }); |
| 540 | const { collapsedFileIds, toggleExpandedFile, toggleViewedFile, viewedFileIds } = fileVisibility; |
| 541 | const commentSelection = useReviewCommentSelectionController({ |
| 542 | environmentId, |
| 543 | threadId, |
| 544 | selectedSection, |
| 545 | nativeReviewDiffData, |
| 546 | }); |
| 547 | const nativeBridge = useNativeReviewDiffBridge({ |
| 548 | threadKey: reviewCache.threadKey, |
| 549 | sectionId: selectedSection?.id ?? null, |
| 550 | diff: selectedSection?.diff, |
| 551 | data: nativeReviewDiffData, |
| 552 | collapsedFileIds, |
| 553 | viewedFileIds, |
| 554 | selectedRowIds: commentSelection.selectedRowIds, |
| 555 | canHighlight: parsedDiff.kind === "files", |
| 556 | }); |
| 557 | const showcaseReviewKey = |
| 558 | SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection |
| 559 | ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}` |
| 560 | : null; |
| 561 | const handleNativeDebug = useCallback( |
| 562 | (event: NativeSyntheticEvent<Record<string, unknown>>) => { |
| 563 | nativeBridge.onDebug(event); |
| 564 | if ( |
| 565 | showcaseReviewKey === null || |
| 566 | showcasedReviewDrawRef.current === showcaseReviewKey || |
| 567 | !isNativeReviewDiffDrawEvent(event.nativeEvent) |
| 568 | ) { |
| 569 | return; |
| 570 | } |
| 571 | showcasedReviewDrawRef.current = showcaseReviewKey; |
| 572 | reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId }); |
| 573 | }, |
| 574 | [nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey], |
| 575 | ); |
| 576 | |
| 577 | const handleSelectFile = useCallback( |
| 578 | (fileId: string | null) => { |
| 579 | loadVisibleFile(fileId, true); |
| 580 | commentSelection.clearSelection(); |
| 581 | if (fileId !== null && collapsedFileIds.includes(fileId)) { |
| 582 | toggleExpandedFile(fileId); |
| 583 | } |
| 584 | const navigation = |
| 585 | fileId === null |
| 586 | ? nativeReviewDiffViewRef.current?.scrollToTop(true) |
| 587 | : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true); |
| 588 | void navigation?.catch((error: unknown) => { |
| 589 | console.error("[review] Failed to navigate to diff file", error); |
| 590 | }); |
| 591 | }, |
| 592 | [collapsedFileIds, commentSelection, toggleExpandedFile, loadVisibleFile], |
| 593 | ); |
| 594 | const handleVisibleFileChange = useCallback( |
| 595 | (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => { |
| 596 | loadVisibleFile(event.nativeEvent.fileId ?? null); |
| 597 | reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null); |
| 598 | }, |
| 599 | [loadVisibleFile], |
| 600 | ); |
| 601 | const renderInspector = useCallback( |
| 602 | () => ( |
| 603 | <ReviewFileNavigator |
| 604 | ref={reviewFileNavigatorRef} |
| 605 | files={nativeReviewDiffData.files} |
| 606 | // The workspace inspector column spans the full window height, so the |
| 607 | // pane clears the status bar itself. |
| 608 | headerInset={insets.top} |
| 609 | sectionId={selectedSection?.id ?? null} |
| 610 | onSelectFile={handleSelectFile} |
| 611 | /> |
| 612 | ), |
| 613 | [handleSelectFile, insets.top, nativeReviewDiffData.files, selectedSection?.id], |
| 614 | ); |
| 615 | |
| 616 | const handleNativeToggleFile = useCallback( |
| 617 | (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { |
| 618 | const { fileId } = event.nativeEvent; |
| 619 | if (fileId) { |
| 620 | loadVisibleFile(fileId, true); |
| 621 | toggleExpandedFile(fileId); |
| 622 | } |
| 623 | }, |
| 624 | [toggleExpandedFile, loadVisibleFile], |
| 625 | ); |
| 626 | |
| 627 | const handleNativeToggleViewedFile = useCallback( |
| 628 | (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { |
| 629 | const { fileId } = event.nativeEvent; |
| 630 | if (fileId) { |
| 631 | toggleViewedFile(fileId); |
| 632 | } |
| 633 | }, |
| 634 | [toggleViewedFile], |
| 635 | ); |
| 636 | |
| 637 | const parsedDiffNotice = |
| 638 | parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; |
| 639 | const hasCachedSelectedDiff = selectedSection?.diff != null; |
| 640 | const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); |
| 641 | const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]); |
| 642 | const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ |
| 643 | hasEnvironmentPresentation: environment.isReady, |
| 644 | isEnvironmentConnected: isEnvironmentReady, |
| 645 | hasCachedSelectedDiff, |
| 646 | hasAnyCachedDiff, |
| 647 | }); |
| 648 | const handleRetryEnvironment = useCallback(() => { |
| 649 | void retryEnvironment(environmentId); |
| 650 | }, [environmentId, retryEnvironment]); |
| 651 | const handleReturnToThread = useCallback(() => { |
| 652 | if (navigation.canGoBack()) { |
| 653 | navigation.goBack(); |
| 654 | return; |
| 655 | } |
| 656 | navigation.navigate("Thread", { |
| 657 | environmentId: String(environmentId), |
| 658 | threadId: String(threadId), |
| 659 | }); |
| 660 | }, [environmentId, navigation, threadId]); |
| 661 | const androidHeaderSubtitle = [ |
| 662 | selectedSection?.title, |
| 663 | headerDiffSummary.additions, |
| 664 | headerDiffSummary.deletions, |
| 665 | ] |
| 666 | .filter((part): part is string => Boolean(part)) |
| 667 | .join(" · "); |
| 668 | |
| 669 | // The changed-files navigator drives the native diff surface via |
| 670 | // scrollToFile, so it is only useful when that surface resolved. In raw |
| 671 | // fallback mode the ref is necessarily null and the raw patch neither |
| 672 | // scrolls nor filters — registering the navigator would present working |
| 673 | // controls that cannot navigate. |
| 674 | const showChangedFilesPane = |
| 675 | !showConnectionNotice && |
| 676 | selectedSection !== null && |
| 677 | parsedDiff.kind === "files" && |
| 678 | NativeReviewDiffView !== null; |
| 679 | useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined); |
| 680 | // A toggle needs registered content; loading, errors and raw patches have no navigator pane. |
| 681 | const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane; |
| 682 | |
| 683 | const listHeader = useMemo(() => { |
| 684 | const children: ReactElement[] = []; |
| 685 | |
| 686 | if (error) { |
| 687 | children.push( |
| 688 | <View |
| 689 | key="review-error" |
| 690 | className={cn( |
| 691 | "bg-card px-4 py-3", |
| 692 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border", |
| 693 | )} |
| 694 | > |
| 695 | <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text> |
| 696 | <Text className="text-xs leading-normal text-foreground-muted">{error}</Text> |
| 697 | </View>, |
| 698 | ); |
| 699 | } |
| 700 | |
| 701 | if (parsedDiffNotice) { |
| 702 | children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />); |
| 703 | } |
| 704 | |
| 705 | if (children.length === 0) { |
| 706 | return null; |
| 707 | } |
| 708 | |
| 709 | return <>{children}</>; |
| 710 | }, [error, parsedDiffNotice]); |
| 711 | const headerSubtitle = [ |
| 712 | headerDiffSummary.additions, |
| 713 | headerDiffSummary.deletions, |
| 714 | pendingReviewCommentCount > 0 |
| 715 | ? `${pendingReviewCommentCount} comment${pendingReviewCommentCount === 1 ? "" : "s"}` |
| 716 | : null, |
| 717 | ] |
| 718 | .filter(Boolean) |
| 719 | .join(" · "); |
| 720 | const headerTitleText = selectedSection?.title ?? "Review changes"; |
| 721 | |
| 722 | return ( |
| 723 | <> |
| 724 | <ReviewHeader |
| 725 | environmentId={environmentId} |
| 726 | threadId={threadId} |
| 727 | title={headerTitleText} |
| 728 | subtitle={headerSubtitle} |
| 729 | androidSubtitle={androidHeaderSubtitle} |
| 730 | iconColor={headerIcon} |
| 731 | selectedThreadCwd={selectedThreadCwd} |
| 732 | sectionMenu={sectionMenu} |
| 733 | selectedSection={selectedSection} |
| 734 | showSectionToolbar={showSectionToolbar} |
| 735 | showChangedFilesToggle={showChangedFilesToggle} |
| 736 | onRefresh={handlePullToRefresh} |
| 737 | onSelectSection={selectSection} |
| 738 | onReturnToThread={handleReturnToThread} |
| 739 | /> |
| 740 | |
| 741 | <MaterialScreenContent> |
| 742 | <View className="flex-1 bg-sheet android:bg-sheet-solid"> |
| 743 | {showConnectionNotice ? ( |
| 744 | <View className="flex-1" style={{ paddingTop: topContentInset }}> |
| 745 | <EnvironmentConnectionNotice |
| 746 | environmentLabel={environment.presentation?.entry.target.label ?? "Environment"} |
| 747 | connection={ |
| 748 | environment.presentation?.connection ?? { |
| 749 | phase: "available", |
| 750 | error: null, |
| 751 | traceId: null, |
| 752 | } |
| 753 | } |
| 754 | resourceName="review" |
| 755 | onRetry={handleRetryEnvironment} |
| 756 | /> |
| 757 | </View> |
| 758 | ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( |
| 759 | <View |
| 760 | className="flex-1" |
| 761 | style={{ |
| 762 | backgroundColor: nativeBridge.theme.background, |
| 763 | }} |
| 764 | > |
| 765 | <View |
| 766 | className="min-w-0 flex-1" |
| 767 | style={listHeader ? { paddingTop: topContentInset } : undefined} |
| 768 | > |
| 769 | {listHeader} |
| 770 | <View className="min-w-0 flex-1" collapsable={false}> |
| 771 | <NativeReviewDiffView |
| 772 | collapsable={false} |
| 773 | testID="review-native-diff-view" |
| 774 | refreshing={ |
| 775 | isPullRefreshing || isSelectedSectionPending || areFilePatchesPending |
| 776 | } |
| 777 | onPullToRefresh={() => void handlePullToRefresh()} |
| 778 | style={StyleSheet.absoluteFill} |
| 779 | appearanceScheme={selectedTheme} |
| 780 | collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} |
| 781 | collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} |
| 782 | contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} |
| 783 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} |
| 784 | contentInsetTop={listHeader ? 0 : topContentInset} |
| 785 | contentInsetBottom={bottomContentInset} |
| 786 | nativeViewRef={nativeReviewDiffViewRef} |
| 787 | rowHeight={nativeReviewDiffStyle.rowHeight} |
| 788 | rowsJson={nativeBridge.rowsJson} |
| 789 | selectedRowIdsJson={nativeBridge.selectedRowIdsJson} |
| 790 | styleJson={nativeBridge.styleJson} |
| 791 | themeJson={nativeBridge.themeJson} |
| 792 | tokensPatchJson={nativeBridge.tokensPatchJson} |
| 793 | tokensResetKey={nativeBridge.tokensResetKey} |
| 794 | viewedFileIdsJson={nativeBridge.viewedFileIdsJson} |
| 795 | onDebug={handleNativeDebug} |
| 796 | onPressLine={commentSelection.onPressLine} |
| 797 | onVisibleFileChange={handleVisibleFileChange} |
| 798 | onToggleComment={nativeBridge.onToggleComment} |
| 799 | onToggleFile={handleNativeToggleFile} |
| 800 | onToggleViewedFile={handleNativeToggleViewedFile} |
| 801 | /> |
| 802 | </View> |
| 803 | </View> |
| 804 | </View> |
| 805 | ) : ( |
| 806 | <ScrollView |
| 807 | contentContainerStyle={ |
| 808 | Platform.OS === "android" && (parsedDiff.kind === "empty" || !selectedSection) |
| 809 | ? { flexGrow: 1, justifyContent: "center" } |
| 810 | : undefined |
| 811 | } |
| 812 | contentInsetAdjustmentBehavior="never" |
| 813 | contentInset={{ top: topContentInset, bottom: bottomContentInset }} |
| 814 | contentOffset={{ x: 0, y: -topContentInset }} |
| 815 | automaticallyAdjustsScrollIndicatorInsets={false} |
| 816 | scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }} |
| 817 | showsVerticalScrollIndicator={false} |
| 818 | className="flex-1" |
| 819 | refreshControl={ |
| 820 | // The native diff surface owns pull-to-refresh via onPullToRefresh; |
| 821 | // the raw fallback (and empty states) need an explicit control — |
| 822 | // iOS has no other refresh affordance here (the explicit |
| 823 | // "Refresh current diff" menu is Android-only). |
| 824 | <RefreshControl |
| 825 | refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending} |
| 826 | onRefresh={() => void handlePullToRefresh()} |
| 827 | /> |
| 828 | } |
| 829 | > |
| 830 | {listHeader} |
| 831 | {!selectedSection ? ( |
| 832 | error ? null : ( |
| 833 | <View |
| 834 | className={ |
| 835 | Platform.OS === "android" |
| 836 | ? "items-center px-6 py-5" |
| 837 | : "border-b border-border bg-card px-4 py-5" |
| 838 | } |
| 839 | > |
| 840 | <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text> |
| 841 | <Text |
| 842 | className={cn( |
| 843 | "text-xs leading-normal text-foreground-muted", |
| 844 | Platform.OS === "android" && "mt-2 text-center", |
| 845 | )} |
| 846 | > |
| 847 | This thread has no ready turn diffs and the worktree diff is empty. |
| 848 | </Text> |
| 849 | </View> |
| 850 | ) |
| 851 | ) : selectedSection.isLoading && selectedSection.diff === null ? ( |
| 852 | <View |
| 853 | className={cn( |
| 854 | "items-center gap-3 px-4 py-6", |
| 855 | Platform.OS !== "android" && "border-b border-border bg-card", |
| 856 | )} |
| 857 | > |
| 858 | <AppActivityIndicator size="small" /> |
| 859 | <Text className="text-xs text-foreground-muted">Loading diff…</Text> |
| 860 | </View> |
| 861 | ) : parsedDiff.kind === "empty" ? ( |
| 862 | <View |
| 863 | className={ |
| 864 | Platform.OS === "android" |
| 865 | ? "items-center px-6 py-5" |
| 866 | : "border-b border-border bg-card px-4 py-5" |
| 867 | } |
| 868 | > |
| 869 | <Text className="text-sm font-t3-bold text-foreground">No changes</Text> |
| 870 | <Text |
| 871 | className={cn( |
| 872 | "text-xs leading-normal text-foreground-muted", |
| 873 | Platform.OS === "android" && "mt-2 text-center", |
| 874 | )} |
| 875 | > |
| 876 | {selectedSection.subtitle ?? "This diff is empty."} |
| 877 | </Text> |
| 878 | </View> |
| 879 | ) : parsedDiff.kind === "raw" ? ( |
| 880 | <View |
| 881 | className={cn( |
| 882 | "gap-3 bg-card px-4 py-4", |
| 883 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border", |
| 884 | )} |
| 885 | > |
| 886 | <Text className="text-xs leading-normal text-foreground-muted"> |
| 887 | {parsedDiff.reason} |
| 888 | </Text> |
| 889 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> |
| 890 | <Text selectable className="font-mono text-xs leading-relaxed text-foreground"> |
| 891 | {parsedDiff.text} |
| 892 | </Text> |
| 893 | </ScrollView> |
| 894 | </View> |
| 895 | ) : parsedDiff.kind === "files" ? ( |
| 896 | // The native diff surface could not be resolved on this binary; |
| 897 | // degrade to the raw patch instead of crashing the app. |
| 898 | <View |
| 899 | className={cn( |
| 900 | "gap-3 bg-card px-4 py-4", |
| 901 | Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border", |
| 902 | )} |
| 903 | > |
| 904 | <Text className="text-xs leading-normal text-foreground-muted"> |
| 905 | Native diff view unavailable. Showing the raw patch. |
| 906 | </Text> |
| 907 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> |
| 908 | <Text selectable className="font-mono text-xs leading-relaxed text-foreground"> |
| 909 | {selectedSection?.diff ?? ""} |
| 910 | </Text> |
| 911 | </ScrollView> |
| 912 | </View> |
| 913 | ) : null} |
| 914 | </ScrollView> |
| 915 | )} |
| 916 | <ReviewSelectionActionBar |
| 917 | bottomInset={insets.bottom} |
| 918 | title={commentSelection.selectionAction?.title ?? null} |
| 919 | onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null} |
| 920 | onClear={commentSelection.clearSelection} |
| 921 | /> |
| 922 | </View> |
| 923 | </MaterialScreenContent> |
| 924 | </> |
| 925 | ); |
| 926 | } |