Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 79 additions & 37 deletions apps/web/src/components/pullRequest/PullRequestCodeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,76 @@ function parseSlice(slice: DiffSlice, cacheKey: string, ignoreWhitespace: boolea
return parsed;
}

type DiffModel = {
readonly parsedSlices: ReadonlyArray<RenderablePatch | null>;
readonly loadedFiles: ReadonlyArray<Extract<RenderablePatch, { kind: "files" }>["files"][number]>;
readonly sourceFileStats: ReadonlyMap<string, ReturnType<typeof getDiffLineStat>>;
};
const diffModels = new WeakMap<ReadonlyArray<DiffSlice>, Map<string, DiffModel>>();
const patchHashes = new WeakMap<DiffSlice, number>();

// Hashing, ordering and counting every file of a huge pull request costs a second, and the tab
// renders far more often than its slices change, so the model is kept per slice list.
function diffModel(
slices: ReadonlyArray<DiffSlice>,
keyPrefix: string,
ignoreWhitespace: boolean,
): DiffModel {
let models = diffModels.get(slices);
if (models === undefined) diffModels.set(slices, (models = new Map()));
const cached = models.get(keyPrefix);
if (cached !== undefined) return cached;
const parsedSlices = slices.map((slice) => {
let hash = patchHashes.get(slice);
if (hash === undefined) patchHashes.set(slice, (hash = fnv1a32(slice.patch)));
// The cache key carries the theme, which the tokenizer caches against, and the patch's
// hash: a refreshed page reuses its cursor, so position alone would return a stale parse.
return parseSlice(slice, `${keyPrefix}:${slice.cursor ?? "first"}:${hash}`, ignoreWhitespace);
});
const model: DiffModel = {
parsedSlices,
loadedFiles: orderDiffFilesLikeTree(
parsedSlices.flatMap((parsed) =>
parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [],
),
),
sourceFileStats: new Map(
parsedSlices.flatMap((parsed) =>
parsed?.kind === "files"
? parsed.files.map(
(file, index) =>
[
buildFileDiffRenderKey(file),
getDiffLineStat([parsed.sourceFiles[index] ?? file]),
] as const,
)
: [],
),
),
};
models.set(keyPrefix, model);
return model;
}

const threadsByPathCache = new WeakMap<
ReadonlyArray<PullRequestReviewThread>,
ReadonlyMap<string, ReadonlyArray<PullRequestReviewThread>>
>();

function reviewThreadsByPath(threads: ReadonlyArray<PullRequestReviewThread>) {
let byPath = threadsByPathCache.get(threads);
if (byPath === undefined) {
const grouped = new Map<string, PullRequestReviewThread[]>();
for (const thread of threads) {
const list = grouped.get(thread.path);
if (list) list.push(thread);
else grouped.set(thread.path, [thread]);
}
threadsByPathCache.set(threads, (byPath = grouped));
}
return byPath;
}

interface FileViewedToggleState {
readonly enabled: boolean;
readonly isViewed: (path: string) => boolean;
Expand Down Expand Up @@ -490,20 +560,10 @@ function PullRequestCodeTab({
// Agent context is local, so it does not require host comment permission.
const canSelectLines =
commit === null && (review.inlineComment || onAddToAgentSelection !== undefined);
// The cache key carries the theme, which is what the tokenizer caches against, so a theme
// change is still a fresh parse. The patch's own hash is part of it too: a refreshed page
// reuses its cursor, and a key of position alone would hand back the parse it replaced.
const parsedSlices = loadedSlices.map((slice) =>
parseSlice(
slice,
`pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`,
ignoreWhitespace,
),
);
const loadedFiles = orderDiffFilesLikeTree(
parsedSlices.flatMap((parsed) =>
parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [],
),
const { parsedSlices, loadedFiles, sourceFileStats } = diffModel(
loadedSlices,
`pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}`,
ignoreWhitespace,
);
const testFileCount = loadedFiles.filter((file) =>
isTestChangedFile(resolveFileDiffPath(file)),
Expand All @@ -522,33 +582,16 @@ function PullRequestCodeTab({
slice.omittedFileStats.map((file) => [file.path, file] as const),
),
);
const sourceFileStats = new Map(
parsedSlices.flatMap((parsed) =>
parsed?.kind === "files"
? parsed.files.map(
(file, index) =>
[
buildFileDiffRenderKey(file),
getDiffLineStat([parsed.sourceFiles[index] ?? file]),
] as const,
)
: [],
),
);
const fileTreeEntries = diffFileTreeEntries(files);
const threadsByPath = reviewThreadsByPath(detail.reviewThreads);
// File membership does not guarantee a rendered hunk for the thread.
const placedThreadIds = (() => {
const placed = new Set<string>();
// Commit diffs use different line coordinates; list conversations separately.
if (commit !== null) return placed;
for (const file of files) {
const path = resolveFileDiffPath(file);
for (const thread of detail.reviewThreads) {
if (
thread.path === path &&
thread.line !== null &&
isLineInFileDiff(file, thread.side, thread.line)
) {
for (const thread of threadsByPath.get(resolveFileDiffPath(file)) ?? []) {
if (thread.line !== null && isLineInFileDiff(file, thread.side, thread.line)) {
placed.add(thread.id);
}
}
Expand All @@ -575,9 +618,8 @@ function PullRequestCodeTab({
return created;
};

for (const thread of detail.reviewThreads) {
if (thread.path !== path || thread.line === null) continue;
if (!placedThreadIds.has(thread.id)) continue;
for (const thread of threadsByPath.get(path) ?? []) {
if (thread.line === null || !placedThreadIds.has(thread.id)) continue;
groupAt(thread.side, thread.line).threads.push(thread);
}
if (commit === null) {
Expand Down
Loading