import { useEffect, useMemo } from "react"; import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status"; import { useCumulativeDiff } from "./use-cumulative-diff"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import { usePRDiff } from "@/hooks/domains/github/use-pr-diff"; import { normalizeDiffContent } from "@/components/review/types"; import { createDebugLogger } from "@/lib/debug/log"; import type { ReviewFile } from "@/components/review/types"; import type { PRDiffFile } from "@/lib/types/github"; const debug = createDebugLogger("review:sources"); export type ReviewSource = "uncommitted" | "committed" | "pr"; export type SourceCounts = Record; type UncommittedFile = { diff?: string; diff_skip_reason?: ReviewFile["diff_skip_reason"]; status?: string; additions?: number; deletions?: number; staged?: boolean; }; type CumulativeFile = { diff?: string; status?: string; additions?: number; deletions?: number; repository_name?: string; }; function addUncommittedFiles( fileMap: Map, files: Record, repositoryName?: string, ) { for (const [path, file] of Object.entries(files)) { const diff = file.diff ? normalizeDiffContent(file.diff) : ""; const skipReason = file.diff_skip_reason; if (!diff && !skipReason) continue; const key = repositoryName ? `${repositoryName}:${path}` : path; fileMap.set(key, { path, diff, status: file.status ?? "modified", additions: file.additions ?? 0, deletions: file.deletions ?? 0, staged: file.staged ?? false, source: "uncommitted", diff_skip_reason: skipReason, repository_name: repositoryName, }); } } function addCumulativeFiles( fileMap: Map, files: Record, uncommittedPaths: Set, ) { for (const [path, file] of Object.entries(files)) { const key = file.repository_name ? `${file.repository_name}:${path}` : path; const hasRepoUnawareCollision = key !== path && fileMap.has(path); if (fileMap.has(key) || uncommittedPaths.has(key) || hasRepoUnawareCollision) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (!diff) continue; fileMap.set(key, { path, diff, status: file.status || "modified", additions: file.additions ?? 0, deletions: file.deletions ?? 0, staged: false, source: "committed", repository_name: file.repository_name, }); } } function prFileStatus(status: string): "added" | "deleted" | "modified" { if (status === "added") return "added"; if (status === "removed") return "deleted"; return "modified"; } function addPRFiles( fileMap: Map, files: PRDiffFile[], uncommittedPaths: Set, repoName?: string, ) { for (const file of files) { const key = repoName ? `${repoName}:${file.filename}` : file.filename; const hasRepoUnawareCollision = key !== file.filename && fileMap.has(file.filename); if (fileMap.has(key) || uncommittedPaths.has(key) || hasRepoUnawareCollision) continue; const diff = file.patch ? normalizeDiffContent(file.patch) : ""; if (!diff) continue; fileMap.set(key, { path: file.filename, diff, status: prFileStatus(file.status), additions: file.additions ?? 0, deletions: file.deletions ?? 0, staged: false, source: "pr", repository_name: repoName, }); } } function collectPathsFromFiles( paths: Set, files: Record, repositoryName?: string, ): void { for (const [path, file] of Object.entries(files)) { const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (diff || file.diff_skip_reason) { // Always add bare path (for deduping repo-unaware sources like cumulative // diffs that may not carry repository_name). paths.add(path); // Also add composite key (for repo-aware dedup when sources carry repo info). if (repositoryName) paths.add(`${repositoryName}:${path}`); } } } function collectUncommittedPaths( statusByRepo: BuildReviewSourcesInput["statusByRepo"], gitStatus: BuildReviewSourcesInput["gitStatus"], ): Set { const paths = new Set(); if (statusByRepo && statusByRepo.length > 0) { for (const { repository_name, status } of statusByRepo) { if (status?.files) collectPathsFromFiles( paths, status.files as Record, repository_name || undefined, ); } } else if (gitStatus?.files) { collectPathsFromFiles(paths, gitStatus.files as Record); } return paths; } export type BuildReviewSourcesInput = { gitStatus: { files?: Record } | undefined; statusByRepo: | Array<{ repository_name: string; status: { files?: Record } }> | undefined; cumulativeDiff: { files?: Record } | null; prDiffFiles: PRDiffFile[] | undefined; /** Repository name for the primary PR — used to composite-key PR files so * same-named files in different repos are not incorrectly deduped. */ prRepoName?: string; }; export type BuildReviewSourcesResult = { allFiles: ReviewFile[]; sourceCounts: SourceCounts; }; /** * Pure helper that merges the three diff sources into one sorted, deduped * list and computes per-source counts. Uncommitted files write first under * composite `repo:path` keys (multi-repo) or simple `path` keys (single-repo). * Committed and PR files write next under simple `path` keys but skip any * path that already appears in the uncommitted set — preserving dedup * priority (uncommitted > committed > PR) across the key-shape mismatch. */ export function buildReviewSources(input: BuildReviewSourcesInput): BuildReviewSourcesResult { const { gitStatus, statusByRepo, cumulativeDiff, prDiffFiles, prRepoName } = input; const fileMap = new Map(); const uncommittedPaths = collectUncommittedPaths(statusByRepo, gitStatus); if (statusByRepo && statusByRepo.length > 0) { for (const { repository_name, status } of statusByRepo) { if (status?.files) { addUncommittedFiles( fileMap, status.files as Record, repository_name || undefined, ); } } } else if (gitStatus?.files) { addUncommittedFiles(fileMap, gitStatus.files as Record); } if (cumulativeDiff?.files) addCumulativeFiles(fileMap, cumulativeDiff.files, uncommittedPaths); if (prDiffFiles && prDiffFiles.length > 0) addPRFiles(fileMap, prDiffFiles, uncommittedPaths, prRepoName); const allFiles = Array.from(fileMap.values()).sort((a, b) => { const repoCmp = (a.repository_name ?? "").localeCompare(b.repository_name ?? ""); if (repoCmp !== 0) return repoCmp; return a.path.localeCompare(b.path); }); const sourceCounts: SourceCounts = { uncommitted: 0, committed: 0, pr: 0 }; for (const f of allFiles) sourceCounts[f.source]++; return { allFiles, sourceCounts }; } export type UseReviewSourcesResult = { allFiles: ReviewFile[]; sourceCounts: SourceCounts; hasPR: boolean; cumulativeLoading: boolean; prDiffLoading: boolean; /** Raw single-repo gitStatus (kept for `useAutoCloseWhenEmpty` consumers). */ gitStatus: ReturnType; /** * Raw PR diff files as ReviewFile[], NOT deduplicated with uncommitted/committed. * Use when displaying the PR-specific diff for a file (e.g. clicking a PR file row), * where the same file may also have local changes that would win deduplication in allFiles. * NOTE: Only covers the primary PR (single-repo tasks). Multi-PR support is Bug 2. */ rawPRFiles: ReviewFile[]; }; /** * Multi-source merge hook. Aggregates uncommitted / committed / PR diffs * into one sorted ReviewFile[] tagged with `.source`. Shared by * `TaskChangesPanel` (diff viewer) and `MobileDiffSheet` (source tab bar). * * Returns counts per source so consumers can render tab badges without * re-running the merge. */ export function useReviewSources(sessionId: string | null | undefined): UseReviewSourcesResult { const gitStatus = useSessionGitStatus(sessionId ?? null); const statusByRepo = useSessionGitStatusByRepo(sessionId ?? null); const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(sessionId ?? null); const pr = useActiveTaskPR(); const { files: prDiffFiles, loading: prDiffLoading } = usePRDiff( pr?.owner ?? null, pr?.repo ?? null, pr?.pr_number ?? null, ); const { allFiles, sourceCounts } = useMemo( () => buildReviewSources({ gitStatus, statusByRepo, cumulativeDiff: cumulativeDiff as { files?: Record } | null, prDiffFiles: prDiffFiles.length > 0 ? prDiffFiles : undefined, prRepoName: pr?.repo || undefined, }), [gitStatus, statusByRepo, cumulativeDiff, prDiffFiles, pr?.repo], ); const rawPRFiles = useMemo(() => { if (!prDiffFiles || prDiffFiles.length === 0) return [] as ReviewFile[]; const fileMap = new Map(); addPRFiles(fileMap, prDiffFiles, new Set(), pr?.repo || undefined); return Array.from(fileMap.values()); }, [prDiffFiles, pr?.repo]); // Keep `hasPR` keyed on the existence of a TaskPR row, not on whether the // PR diff files have loaded yet — avoids the tab bar reflowing the moment // the GitHub PR diff hydrates. const hasPR = !!pr; useEffect(() => { if (!sessionId) return; debug("merged", { sessionId, total: allFiles.length, uncommitted: sourceCounts.uncommitted, committed: sourceCounts.committed, pr: sourceCounts.pr, hasPR, hasCumulativeDiff: !!cumulativeDiff, cumulativeLoading, prDiffLoading, prRepo: pr?.repo ?? null, prNumber: pr?.pr_number ?? null, }); }, [ sessionId, allFiles.length, sourceCounts.uncommitted, sourceCounts.committed, sourceCounts.pr, hasPR, cumulativeDiff, cumulativeLoading, prDiffLoading, pr?.repo, pr?.pr_number, ]); return { allFiles, sourceCounts, hasPR, cumulativeLoading, prDiffLoading, gitStatus, rawPRFiles, }; } export type { CumulativeFile, UncommittedFile };