import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { FlatList } from 'react-native' import type { DiffComment } from '../../../src/shared/diff-comment-types' import type { ConnectionState } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' import { getWorktreeLabel } from './worktree-label' import { getUnsentMobileDiffComments } from './mobile-diff-comment-edit' import { buildMobileDiffReviewQueue, filterMobileDiffReviewQueue, mobileDiffReviewCommentMatchesItem, type MobileDiffReviewQueueFilter, type MobileDiffReviewQueueItem } from './mobile-diff-review-queue' import { findMobileDiffReviewInitialIndex, type MobileDiffReviewInitialTarget } from './mobile-diff-review-positioning' import { loadMobileDiffReviewSnapshot } from './mobile-diff-review-loaders' import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading' import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare' import type { ComposerState, ReviewDiffLine, ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model' import { useMobileDiffReviewInteractions } from './use-mobile-diff-review-interactions' import { useMobilePrSidebarController } from './use-mobile-pr-sidebar-controller' type ControllerInput = { client: RpcClient | null connState: ConnectionState hostId: string worktreeId: string name: string initialFilter: MobileDiffReviewQueueFilter initialTarget: MobileDiffReviewInitialTarget | null onOpenSession: () => void onReconnect: (hostId: string) => void | Promise } export function useMobileDiffReviewController(input: ControllerInput) { const { client, connState, hostId, worktreeId, name, initialFilter, initialTarget, onOpenSession, onReconnect } = input const listRef = useRef | null>(null) const loadGenerationRef = useRef(0) const seededInitialTargetRef = useRef(false) const initialTargetKey = initialTarget ? `${initialTarget.area}\0${initialTarget.filePath}` : '' const [screenState, setScreenState] = useState({ kind: 'loading' }) const [filter, setFilter] = useState(initialFilter) const [currentIndex, setCurrentIndex] = useState(0) const [activeHunkIndex, setActiveHunkIndex] = useState(null) const [composer, setComposer] = useState(null) const [composerBody, setComposerBody] = useState('') const [actionError, setActionError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [discardTarget, setDiscardTarget] = useState(null) const [showOverflow, setShowOverflow] = useState(false) const [sendSheet, setSendSheet] = useState(null) const [showCompletion, setShowCompletion] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) const loadReviewData = useCallback(async () => { const generation = loadGenerationRef.current + 1 loadGenerationRef.current = generation const isCurrent = () => generation === loadGenerationRef.current if (!worktreeId) { setScreenState({ kind: 'error', message: 'Missing worktree' }) return } // Why (F10): a loaded review outlives a blip — the waiting state is for a screen with nothing // to show, and this branch (not the one below it) is the one a drop actually reaches. const keepReady = (fallback: ReviewScreenState) => (prev: ReviewScreenState) => prev.kind === 'ready' ? prev : fallback if (!client || connState !== 'connected') { setScreenState(keepReady({ kind: 'error', message: 'Waiting for desktop...' })) return } setScreenState(keepReady({ kind: 'loading' })) try { const nextState = await loadMobileDiffReviewSnapshot(client, worktreeId) if (!isCurrent()) { return } setScreenState(nextState) setActionError(nextState.kind === 'ready' ? (nextState.branchError ?? null) : null) } catch (err) { if (isCurrent()) { // Why (F10): a failed refresh after reconnect must not destroy the review // already on screen; the error state is for a screen with nothing to show. setScreenState( keepReady({ kind: 'error', message: err instanceof Error ? err.message : 'Unable to load review' }) ) } } }, [client, connState, worktreeId]) useEffect(() => { void loadReviewData() }, [loadReviewData]) const queue = useMemo(() => { if (screenState.kind !== 'ready') { return [] } const branchEntries = screenState.branchCompare && canOpenMobileBranchCompareDiff(screenState.branchCompare.summary) ? screenState.branchCompare.entries : [] return buildMobileDiffReviewQueue({ worktreeId, statusEntries: screenState.status.entries, branchEntries, branchHeadOid: screenState.branchCompare?.summary.headOid, branchMergeBase: screenState.branchCompare?.summary.mergeBase, comments: screenState.comments, reviewState: screenState.reviewState }) }, [screenState, worktreeId]) const filteredQueue = useMemo(() => filterMobileDiffReviewQueue(queue, filter), [filter, queue]) const currentItem = filteredQueue[currentIndex] ?? null const reviewedCount = queue.filter((item) => item.isReviewed).length const unsentComments = screenState.kind === 'ready' ? getUnsentMobileDiffComments(screenState.comments) : [] const reviewedUnstagedCount = queue.filter( (item) => item.scope === 'unstaged' && item.isReviewed && item.canStage ).length useEffect(() => { seededInitialTargetRef.current = false }, [initialTargetKey]) useEffect(() => { if (seededInitialTargetRef.current || filteredQueue.length === 0) { return } seededInitialTargetRef.current = true // Why: review data loads asynchronously; seed the tapped file only after the // first real queue exists so the clamp effect cannot reset it back to zero. setCurrentIndex(findMobileDiffReviewInitialIndex(filteredQueue, initialTarget)) }, [filteredQueue, initialTarget]) useEffect(() => { if (filteredQueue.length === 0) { setCurrentIndex(0) return } if (currentIndex >= filteredQueue.length) { setCurrentIndex(filteredQueue.length - 1) } }, [currentIndex, filteredQueue.length]) const diffState = useMobileDiffReviewDiffLoading({ client, connState, worktreeId, currentItem, screenState, setActiveHunkIndex }) const commentsForCurrentItem = useMemo(() => { if (!currentItem || screenState.kind !== 'ready') { return [] } return screenState.comments.filter((comment) => mobileDiffReviewCommentMatchesItem(comment, currentItem) ) }, [currentItem, screenState]) const staleCommentIds = useMemo( () => new Set( commentsForCurrentItem .filter( (comment) => currentItem && comment.diffIdentity !== undefined && comment.diffIdentity !== currentItem.diffIdentity ) .map((comment) => comment.id) ), [commentsForCurrentItem, currentItem] ) const commentsByLine = useMemo(() => { const map = new Map() for (const comment of commentsForCurrentItem) { const list = map.get(comment.lineNumber) ?? [] list.push(comment) map.set(comment.lineNumber, list) } return map }, [commentsForCurrentItem]) // Head branch + SHA for the PR sidebar come from git.status (the review snapshot), // not the branchCompare base ref. headOid is the branch-compare fallback for the SHA. const prSidebarBranch = screenState.kind === 'ready' ? (screenState.status.branch ?? null) : null const prSidebarHeadSha = screenState.kind === 'ready' ? (screenState.status.head ?? screenState.branchCompare?.summary.headOid ?? null) : null const prSidebar = useMobilePrSidebarController({ client, connState, worktreeId, branch: prSidebarBranch, headSha: prSidebarHeadSha }) const interactions = useMobileDiffReviewInteractions({ client, connState, hostId, worktreeId, screenState, diffState, currentItem, queue, filteredQueue, filter, currentIndex, activeHunkIndex, composer, composerBody, listRef, setScreenState, setFilter, setCurrentIndex, setActiveHunkIndex, setComposer, setComposerBody, setActionError, setBusyAction, setSendSheet, setShowCompletion, loadReviewData, onOpenSession, onReconnect }) return { ...interactions, ...prSidebar, // Exposed so the screen can thread the RPC client + worktree into the PR // sidebar's lazy check-detail fetches (U5) and mutation actions (U6). client, connState, worktreeId, prSidebarBranch, prSidebarHeadSha, actionError, activeHunkIndex, busyAction, commentsByLine, composer, composerBody, currentIndex, currentItem, diffState, discardTarget, fileNotes: commentsByLine.get(0) ?? [], filter, filteredQueue, listRef, queue, reviewedCount, reviewedUnstagedCount, screenState, sendSheet, setComposerBody, setDiscardTarget, setSendSheet, setShowCompletion, setShowOverflow, showCompletion, showOverflow, staleCommentIds, unsentComments, worktreeLabel } }