Files
orca/mobile/src/session/use-mobile-diff-review-controller.ts
T

298 lines
9.4 KiB
TypeScript

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,
summarizeMobileDiffReviewQueue,
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<void>
}
export function useMobileDiffReviewController(input: ControllerInput) {
const {
client,
connState,
hostId,
worktreeId,
name,
initialFilter,
initialTarget,
onOpenSession,
onReconnect
} = input
const listRef = useRef<FlatList<ReviewDiffLine> | null>(null)
const loadGenerationRef = useRef(0)
const seededInitialTargetRef = useRef(false)
const initialTargetKey = initialTarget ? `${initialTarget.area}\0${initialTarget.filePath}` : ''
const [screenState, setScreenState] = useState<ReviewScreenState>({ kind: 'loading' })
const [filter, setFilter] = useState<MobileDiffReviewQueueFilter>(initialFilter)
const [currentIndex, setCurrentIndex] = useState(0)
const [activeHunkIndex, setActiveHunkIndex] = useState<number | null>(null)
const [composer, setComposer] = useState<ComposerState | null>(null)
const [composerBody, setComposerBody] = useState('')
const [actionError, setActionError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [discardTarget, setDiscardTarget] = useState<MobileDiffReviewQueueItem | null>(null)
const [showOverflow, setShowOverflow] = useState(false)
const [sendSheet, setSendSheet] = useState<SendSheetState | null>(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, reviewedUnstagedCount } = useMemo(
() => summarizeMobileDiffReviewQueue(queue),
[queue]
)
const unsentComments =
screenState.kind === 'ready' ? getUnsentMobileDiffComments(screenState.comments) : []
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<number, DiffComment[]>()
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
}
}