Files
kandev/apps/web/hooks/domains/session/use-review-sources.ts
T
2026-06-15 18:54:16 +01:00

313 lines
10 KiB
TypeScript

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<ReviewSource, number>;
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<string, ReviewFile>,
files: Record<string, UncommittedFile>,
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<string, ReviewFile>,
files: Record<string, CumulativeFile>,
uncommittedPaths: Set<string>,
) {
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<string, ReviewFile>,
files: PRDiffFile[],
uncommittedPaths: Set<string>,
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<string>,
files: Record<string, UncommittedFile>,
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<string> {
const paths = new Set<string>();
if (statusByRepo && statusByRepo.length > 0) {
for (const { repository_name, status } of statusByRepo) {
if (status?.files)
collectPathsFromFiles(
paths,
status.files as Record<string, UncommittedFile>,
repository_name || undefined,
);
}
} else if (gitStatus?.files) {
collectPathsFromFiles(paths, gitStatus.files as Record<string, UncommittedFile>);
}
return paths;
}
export type BuildReviewSourcesInput = {
gitStatus: { files?: Record<string, UncommittedFile> } | undefined;
statusByRepo:
| Array<{ repository_name: string; status: { files?: Record<string, UncommittedFile> } }>
| undefined;
cumulativeDiff: { files?: Record<string, CumulativeFile> } | 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<string, ReviewFile>();
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<string, UncommittedFile>,
repository_name || undefined,
);
}
}
} else if (gitStatus?.files) {
addUncommittedFiles(fileMap, gitStatus.files as Record<string, UncommittedFile>);
}
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<typeof useSessionGitStatus>;
/**
* 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<string, CumulativeFile> } | 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<string, ReviewFile>();
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 };