mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
298 lines
9.4 KiB
TypeScript
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
|
|
}
|
|
}
|