authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-09-16 22:44:51+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 18:44:51-03:00
logaae361c4ef6478e4dcf970d574f3fd38d908e6b9
tree13042789147074367f749dadc43eed60f738b482
parent32e8b2584556c0c55ce0d1d8f72b506cb771d60d
signature Signed by PGP key B5690EEEBB952194

fix(review): show complete counts and load large diffs progressively (#10822)

Co-authored-by: maria-rcks <maria@kuuro.net>

22 files changed, 1483 insertions(+), 389 deletions(-)

apps/mobile/src/features/review/ReviewSheet.tsx+39-20
......@@ -382,24 +382,40 @@ export function ReviewSheet(props: ReviewSheetProps) {
382382 useEffect(() => {
383383 showAuxiliaryPane("inspector");
384384 }, [environmentId, showAuxiliaryPane, threadId]);
385 const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } =
386 useReviewSections({
387 enabled: isEnvironmentReady,
388 environmentId,
389 threadId,
390 reviewCache,
391 });
385 const {
386 error,
387 reviewSections,
388 selectedSection,
389 refreshSelectedSection,
390 selectSection,
391 isSelectedSectionPending,
392 diffPreviewRevision,
393 } = useReviewSections({
394 enabled: isEnvironmentReady,
395 environmentId,
396 threadId,
397 reviewCache,
398 });
392399 useReviewDiffPrewarming({
393400 threadKey: reviewCache.threadKey,
394401 sections: reviewSections,
395402 selectedSectionId: selectedSection?.id ?? null,
396403 });
397 const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
398 useReviewDiffData({
399 threadKey: reviewCache.threadKey,
400 selectedSection,
401 draftMessage,
402 });
404 const {
405 headerDiffSummary,
406 nativeReviewDiffData,
407 parsedDiff,
408 pendingReviewCommentCount,
409 loadVisibleFile,
410 isPending: areFilePatchesPending,
411 } = useReviewDiffData({
412 threadKey: reviewCache.threadKey,
413 environmentId,
414 cwd: selectedThreadCwd,
415 selectedSection,
416 revision: diffPreviewRevision,
417 draftMessage,
418 });
403419 // Resolution returns null while Expo registers the native view (or forever
404420 // when the binary lacks it). Rendering a null component type crashes the
405421 // app, so callers must fall back — ThreadFeed's ReviewCommentCard does the
......@@ -469,6 +485,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
469485
470486 const handleSelectFile = useCallback(
471487 (fileId: string | null) => {
488 loadVisibleFile(fileId, true);
472489 commentSelection.clearSelection();
473490 if (fileId !== null && collapsedFileIds.includes(fileId)) {
474491 toggleExpandedFile(fileId);
......@@ -481,13 +498,14 @@ export function ReviewSheet(props: ReviewSheetProps) {
481498 console.error("[review] Failed to navigate to diff file", error);
482499 });
483500 },
484 [collapsedFileIds, commentSelection, toggleExpandedFile],
501 [collapsedFileIds, commentSelection, toggleExpandedFile, loadVisibleFile],
485502 );
486503 const handleVisibleFileChange = useCallback(
487504 (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => {
505 loadVisibleFile(event.nativeEvent.fileId ?? null);
488506 reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null);
489507 },
490 [],
508 [loadVisibleFile],
491509 );
492510 const renderInspector = useCallback(
493511 () => (
......@@ -508,10 +526,11 @@ export function ReviewSheet(props: ReviewSheetProps) {
508526 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
509527 const { fileId } = event.nativeEvent;
510528 if (fileId) {
529 loadVisibleFile(fileId, true);
511530 toggleExpandedFile(fileId);
512531 }
513532 },
514 [toggleExpandedFile],
533 [toggleExpandedFile, loadVisibleFile],
515534 );
516535
517536 const handleNativeToggleViewedFile = useCallback(
......@@ -574,12 +593,12 @@ export function ReviewSheet(props: ReviewSheetProps) {
574593 (event: { nativeEvent: { event: string } }) => {
575594 const id = event.nativeEvent.event;
576595 if (id === "refresh") {
577 void refreshSelectedSection();
596 void handlePullToRefresh();
578597 } else if (id.startsWith("section:")) {
579598 selectSection(id.slice("section:".length));
580599 }
581600 },
582 [refreshSelectedSection, selectSection],
601 [handlePullToRefresh, selectSection],
583602 );
584603 const handleRetryEnvironment = useCallback(() => {
585604 void retryEnvironment(environmentId);
......@@ -818,7 +837,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
818837 <NativeReviewDiffView
819838 collapsable={false}
820839 testID="review-native-diff-view"
821 refreshing={isPullRefreshing}
840 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}
822841 onPullToRefresh={() => void handlePullToRefresh()}
823842 style={StyleSheet.absoluteFill}
824843 appearanceScheme={selectedTheme}
......@@ -862,7 +881,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
862881 // iOS has no other refresh affordance here (the explicit
863882 // "Refresh current diff" menu is Android-only).
864883 <RefreshControl
865 refreshing={isPullRefreshing}
884 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}
866885 onRefresh={() => void handlePullToRefresh()}
867886 />
868887 }
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+23
......@@ -98,6 +98,29 @@ function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) {
9898}
9999
100100describe("getCachedNativeReviewDiffData", () => {
101 it.each([true, false])(
102 "preserves available diff rows before a notice (has excerpt: %s)",
103 (hasExcerpt) => {
104 if (parsedDiff.kind !== "files") throw new Error("Expected a parsed file diff");
105 const notice = "This file preview was truncated.";
106 const result = buildNativeReviewDiffData({
107 parsedDiff: {
108 ...parsedDiff,
109 files: parsedDiff.files.map((file) => ({
110 ...file,
111 rows: hasExcerpt ? file.rows : [],
112 notice,
113 })),
114 },
115 });
116 const original = buildNativeReviewDiffData({ parsedDiff });
117 expect(result.rows.slice(0, -1)).toEqual(
118 hasExcerpt ? original.rows : original.rows.filter((row) => row.kind === "file"),
119 );
120 expect(result.rows.at(-1)).toMatchObject({ kind: "notice", text: notice });
121 },
122 );
123
101124 it("reuses the row model for equivalent empty comment arrays", () => {
102125 const first = getCachedNativeReviewDiffData(buildInput([]));
103126 const second = getCachedNativeReviewDiffData(buildInput([]));
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+23-9
......@@ -125,6 +125,8 @@ interface PreparedNativeReviewFileRows {
125125 readonly filePath: string;
126126 readonly lineCount: number;
127127 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
128 readonly commentTargetsByRowId: ReadonlyMap<string, NativeReviewDiffCommentTarget>;
129 readonly rowIdByCommentLineId: ReadonlyMap<string, string>;
128130 commentedRows: {
129131 readonly commentsKey: string;
130132 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
......@@ -136,6 +138,7 @@ interface PreparedNativeReviewDiffData extends Omit<NativeReviewDiffData, "rows"
136138}
137139
138140const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>();
141const nativeReviewFileRowsCache = new WeakMap<ReviewRenderableFile, PreparedNativeReviewFileRows>();
139142
140143function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {
141144 if (comments.length === 0) {
......@@ -261,6 +264,7 @@ function createNoticeRow(fileId: string, suffix: string, text: string): NativeRe
261264}
262265
263266function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray<NativeReviewDiffRow> {
267 if (file.notice) return [createNoticeRow(file.id, "loading", file.notice)];
264268 if (file.rows.length > 0) {
265269 return [];
266270 }
......@@ -406,11 +410,11 @@ function mapLineRow(
406410 };
407411}
408412
409function prepareFileRows(
410 file: ReviewRenderableFile,
411 commentTargetsByRowId: Map<string, NativeReviewDiffCommentTarget>,
412 rowIdByCommentLineId: Map<string, string>,
413): PreparedNativeReviewFileRows {
413function prepareFileRows(file: ReviewRenderableFile): PreparedNativeReviewFileRows {
414 const cached = nativeReviewFileRowsCache.get(file);
415 if (cached) return cached;
416 const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>();
417 const rowIdByCommentLineId = new Map<string, string>();
414418 const rows: NativeReviewDiffRow[] = [
415419 {
416420 kind: "file",
......@@ -449,14 +453,18 @@ function prepareFileRows(
449453 });
450454
451455 rows.push(...noticeRowsForFile(file));
452 return {
456 const prepared: PreparedNativeReviewFileRows = {
453457 fileId: file.id,
454458 filePath: file.path,
455459 lineCount: lineRows.length,
456460 // Comments must not split the source deletion/addition runs used for word matching.
457461 rows: addNativeWordDiffRanges(rows),
462 commentTargetsByRowId,
463 rowIdByCommentLineId,
458464 commentedRows: null,
459465 };
466 nativeReviewFileRowsCache.set(file, prepared);
467 return prepared;
460468}
461469
462470function insertFileComments(
......@@ -526,9 +534,15 @@ function prepareNativeReviewDiffData(parsedDiff: ReviewParsedDiff): PreparedNati
526534 }));
527535 const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>();
528536 const rowIdByCommentLineId = new Map<string, string>();
529 const fileRows = parsedDiff.files.map((file) =>
530 prepareFileRows(file, commentTargetsByRowId, rowIdByCommentLineId),
531 );
537 const fileRows = parsedDiff.files.map(prepareFileRows);
538 for (const file of fileRows) {
539 for (const [rowId, target] of file.commentTargetsByRowId) {
540 commentTargetsByRowId.set(rowId, target);
541 }
542 for (const [lineId, rowId] of file.rowIdByCommentLineId) {
543 rowIdByCommentLineId.set(lineId, rowId);
544 }
545 }
532546
533547 return {
534548 fileRows,
apps/mobile/src/features/review/reviewModel.test.ts+61
......@@ -8,6 +8,7 @@ import {
88} from "@t3tools/contracts";
99
1010import {
11 applyReviewDiffMetadata,
1112 buildReviewParsedDiff,
1213 buildReviewSectionItems,
1314 getDefaultReviewSectionId,
......@@ -135,6 +136,35 @@ describe("buildReviewSectionItems", () => {
135136});
136137
137138describe("buildReviewParsedDiff", () => {
139 it.each([
140 ["a/example.ts", "a/example.ts"],
141 ["b/example.ts", "b/example.ts"],
142 ["a/before.ts", "b/after.ts"],
143 ])("preserves repository paths from %s to %s", (previousPath, path) => {
144 const parsed = buildReviewParsedDiff(
145 [
146 `diff --git a/${previousPath} b/${path}`,
147 ...(previousPath === path
148 ? []
149 : ["similarity index 50%", `rename from ${previousPath}`, `rename to ${path}`]),
150 `--- a/${previousPath}`,
151 `+++ b/${path}`,
152 "@@ -1 +1 @@",
153 "-before",
154 "+after",
155 ].join("\n"),
156 "repository-paths",
157 );
158 expect(parsed.kind).toBe("files");
159 if (parsed.kind !== "files") return;
160 expect(parsed.files[0]).toMatchObject({
161 path,
162 previousPath: previousPath === path ? null : previousPath,
163 additions: 1,
164 deletions: 1,
165 });
166 });
167
138168 it("builds renderable rows from a unified patch", () => {
139169 const parsed = buildReviewParsedDiff(
140170 [
......@@ -271,3 +301,34 @@ describe("buildReviewParsedDiff", () => {
271301 });
272302 });
273303});
304
305describe("applyReviewDiffMetadata", () => {
306 it("uses complete counts even when the preview contains only part of one file", () => {
307 const parsed = buildReviewParsedDiff(
308 [
309 "diff --git a/large.txt b/large.txt",
310 "--- a/large.txt",
311 "+++ b/large.txt",
312 "@@ -1 +1 @@",
313 "-before",
314 "+after",
315 ].join("\n"),
316 "partial",
317 );
318 const result = applyReviewDiffMetadata(parsed, {
319 truncated: true,
320 files: [
321 { path: "large.txt", previousPath: null, additions: 4000, deletions: 3000 },
322 { path: "unseen.txt", previousPath: null, additions: 100, deletions: 20 },
323 ],
324 });
325 expect(result.kind).toBe("files");
326 if (result.kind !== "files") return;
327 expect(result.fileCount).toBe(2);
328 expect(result.additions).toBe(4100);
329 expect(result.deletions).toBe(3020);
330 expect(result.files[0]?.additions).toBe(4000);
331 expect(result.notice).toContain("Counts include all changes");
332 expect(applyReviewDiffMetadata(parsed, null)).toEqual(parsed);
333 });
334});
apps/mobile/src/features/review/reviewModel.ts+33-12
......@@ -18,6 +18,9 @@ export interface ReviewSectionItem {
1818 readonly subtitle: string | null;
1919 readonly diff: string | null;
2020 readonly isLoading: boolean;
21 readonly files?: ReviewDiffPreviewSource["files"];
22 readonly truncated?: boolean;
23 readonly source?: ReviewDiffPreviewSource;
2124}
2225
2326export interface ReviewRenderableHunkRow {
......@@ -47,6 +50,7 @@ export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLine
4750export interface ReviewRenderableFile {
4851 readonly id: string;
4952 readonly cacheKey: string;
53 readonly notice?: string;
5054 readonly path: string;
5155 readonly previousPath: string | null;
5256 readonly changeType: ChangeTypes;
......@@ -126,16 +130,6 @@ function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
126130 return "Base branch unavailable";
127131}
128132
129function stripGitPrefix(pathValue: string | undefined): string | null {
130 if (!pathValue) {
131 return null;
132 }
133 if (pathValue.startsWith("a/") || pathValue.startsWith("b/")) {
134 return pathValue.slice(2);
135 }
136 return pathValue;
137}
138
139133function stripTrailingNewline(value: string): string {
140134 return value.endsWith("\n") ? value.slice(0, -1) : value;
141135}
......@@ -378,8 +372,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender
378372}
379373
380374function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile {
381 const path = stripGitPrefix(file.name) ?? stripGitPrefix(file.prevName) ?? file.name;
382 const previousPath = stripGitPrefix(file.prevName);
375 const path = file.name || file.prevName || "";
376 const previousPath = file.prevName || null;
383377 const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0);
384378 const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0);
385379 const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`;
......@@ -442,6 +436,9 @@ export function buildReviewSectionItems(input: {
442436 title: section.title,
443437 subtitle: gitSubtitle(section),
444438 diff: section.diff,
439 source: section,
440 ...(section.files ? { files: section.files } : {}),
441 truncated: section.truncated,
445442 isLoading: false,
446443 }));
447444 const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID);
......@@ -527,3 +524,27 @@ export function buildReviewParsedDiff(
527524 };
528525 }
529526}
527
528export function applyReviewDiffMetadata(
529 previewDiff: ReviewParsedDiff,
530 selectedSection: Pick<ReviewSectionItem, "files" | "truncated"> | null,
531): ReviewParsedDiff {
532 if (previewDiff.kind === "empty") return previewDiff;
533 const notice = selectedSection?.truncated
534 ? `This preview exceeds the size limit. Changes shown are incomplete.${selectedSection.files ? " Counts include all changes." : ""}`
535 : previewDiff.notice;
536 if (previewDiff.kind !== "files" || !selectedSection?.files) return { ...previewDiff, notice };
537 const totals = selectedSection.files.reduce(
538 (total, file) => ({
539 additions: total.additions + file.additions,
540 deletions: total.deletions + file.deletions,
541 }),
542 { additions: 0, deletions: 0 },
543 );
544 const stats = new Map(selectedSection.files.map((file) => [file.path, file]));
545 const files = previewDiff.files.map((file) => {
546 const stat = stats.get(file.path);
547 return stat ? { ...file, additions: stat.additions, deletions: stat.deletions } : file;
548 });
549 return { ...previewDiff, ...totals, files, fileCount: selectedSection.files.length, notice };
550}
apps/mobile/src/features/review/useReviewDiffData.ts+213-19
......@@ -1,12 +1,87 @@
1import { useEffect, useMemo } from "react";
1import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
22
33import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection";
44import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter";
55import { markReviewEvent, measureReviewWork } from "./reviewPerf";
66import { getCachedReviewParsedDiff } from "./reviewState";
7import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel";
7import {
8 applyReviewDiffMetadata,
9 buildReviewParsedDiff,
10 type ReviewParsedDiff,
11 type ReviewRenderableFile,
12 type ReviewSectionItem,
13} from "./reviewModel";
14
15import type {
16 EnvironmentId,
17 ReviewDiffFileStat,
18 ReviewDiffPreviewSource,
19} from "@t3tools/contracts";
20import { RegistryContext, useAtomValue } from "@effect/atom-react";
21import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
22import * as Atom from "effect/unstable/reactivity/Atom";
23import { reviewEnvironment } from "../../state/review";
824
925const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]);
26type ParsedFilePatch = AsyncResult.AsyncResult<
27 { source: ReviewDiffPreviewSource; parsed: ReviewParsedDiff },
28 unknown
29>;
30const normalizedFiles = new WeakMap<
31 ParsedFilePatch,
32 { stat: ReviewDiffFileStat; diffHash: string; file: ReviewRenderableFile }
33>();
34
35function getCachedReviewFile(
36 stat: ReviewDiffFileStat,
37 diffHash: string,
38 patch: ParsedFilePatch | null | undefined,
39): ReviewRenderableFile {
40 const cached = patch && normalizedFiles.get(patch);
41 if (cached && cached.stat === stat && cached.diffHash === diffHash) return cached.file;
42 const parsed = patch?._tag === "Success" ? patch.value.parsed : null;
43 const sourceFiles = patch?._tag === "Success" ? patch.value.source.files : undefined;
44 const loaded =
45 parsed?.kind === "files"
46 ? (parsed.files.find((file) => file.path === stat.path) ??
47 (parsed.files.length === 1 &&
48 sourceFiles?.length === 1 &&
49 sourceFiles[0]?.path === stat.path
50 ? parsed.files[0]
51 : undefined))
52 : undefined;
53 const file: ReviewRenderableFile = {
54 ...(loaded ?? {
55 path: stat.path,
56 previousPath: stat.previousPath,
57 changeType: "change" as const,
58 languageHint: null,
59 additionLines: [],
60 deletionLines: [],
61 rows: [],
62 cacheKey: `${diffHash}:${stat.path}`,
63 }),
64 id: stat.path,
65 path: stat.path,
66 previousPath: stat.previousPath,
67 additions: stat.additions,
68 deletions: stat.deletions,
69 ...(patch?._tag === "Success"
70 ? patch.value.source.truncated
71 ? { notice: "File preview exceeds the size limit. Counts include all changes." }
72 : loaded
73 ? {}
74 : { notice: "Could not display file preview." }
75 : {
76 notice:
77 patch?._tag === "Failure"
78 ? "Could not load diff. Select the file to retry."
79 : "Loading diff…",
80 }),
81 };
82 if (patch) normalizedFiles.set(patch, { stat, diffHash, file });
83 return file;
84}
1085
1186function isReviewDiffDebugLoggingEnabled(): boolean {
1287 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
......@@ -25,39 +100,156 @@ function logReviewDiffDiagnostic(message: string, details?: Record<string, unkno
25100 console.log(`[review-sheet] ${message}`);
26101}
27102
28export function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): {
103export function formatHeaderDiffSummary(
104 parsedDiff: ReviewParsedDiff,
105 files?: ReviewSectionItem["files"],
106): {
29107 readonly additions: string | null;
30108 readonly deletions: string | null;
31109} {
32 if (parsedDiff.kind !== "files") {
33 return { additions: null, deletions: null };
110 if (files) {
111 return {
112 additions: `+${files.reduce((total, file) => total + file.additions, 0)}`,
113 deletions: `-${files.reduce((total, file) => total + file.deletions, 0)}`,
114 };
34115 }
35
36 return {
37 additions: `+${parsedDiff.additions}`,
38 deletions: `-${parsedDiff.deletions}`,
39 };
116 if (parsedDiff.kind !== "files") return { additions: null, deletions: null };
117 return { additions: `+${parsedDiff.additions}`, deletions: `-${parsedDiff.deletions}` };
40118}
41119
42120export function useReviewDiffData(input: {
43121 readonly threadKey: string | null;
122 readonly environmentId: EnvironmentId | undefined;
123 readonly cwd: string | null;
44124 readonly selectedSection: ReviewSectionItem | null;
125 readonly revision: string | undefined;
45126 readonly draftMessage: string;
46127}) {
47128 const { draftMessage, selectedSection, threadKey } = input;
48129 const selectedSectionId = selectedSection?.id ?? null;
49 const parsedDiff = useMemo(
130 const source = selectedSection?.source;
131 const lazySource = source?.truncated && source.files ? source : null;
132 const previewDiff = useMemo<ReviewParsedDiff>(
50133 () =>
51 measureReviewWork("parse-diff", () =>
52 getCachedReviewParsedDiff({
53 threadKey,
54 sectionId: selectedSection?.id ?? null,
55 diff: selectedSection?.diff,
56 }),
134 lazySource
135 ? { kind: "empty" }
136 : measureReviewWork("parse-diff", () =>
137 getCachedReviewParsedDiff({
138 threadKey,
139 sectionId: selectedSection?.id ?? null,
140 diff: selectedSection?.diff,
141 }),
142 ),
143 [lazySource, selectedSection?.diff, selectedSection?.id, threadKey],
144 );
145 const registry = useContext(RegistryContext);
146 const { environmentId, cwd } = input;
147 const scope = JSON.stringify([
148 environmentId,
149 cwd,
150 source?.kind,
151 source?.baseRef,
152 source?.diffHash,
153 ]);
154 const [requested, setRequested] = useState({ scope, indices: [0, 1, 2] });
155 const indices = useMemo(
156 () => new Set(requested.scope === scope ? requested.indices : [0, 1, 2]),
157 [requested, scope],
158 );
159 const queries = useMemo(
160 () =>
161 !environmentId || !cwd || !lazySource
162 ? []
163 : (lazySource.files ?? []).map((file, index) =>
164 indices.has(index)
165 ? reviewEnvironment.diffFilePatch({
166 environmentId,
167 input: {
168 cacheKey: scope,
169 request: {
170 cwd,
171 ...(lazySource.baseRef ? { baseRef: lazySource.baseRef } : {}),
172 file: {
173 path: file.path,
174 previousPath: file.previousPath,
175 sourceKind: lazySource.kind,
176 },
177 },
178 },
179 })
180 : null,
181 ),
182 [environmentId, cwd, lazySource, indices, scope],
183 );
184 const parsedQuery = useMemo(
185 () =>
186 Atom.family((query: ReturnType<typeof reviewEnvironment.diffFilePatch>) =>
187 Atom.map(query, (result) =>
188 AsyncResult.map(result, (source) => ({
189 source,
190 parsed: buildReviewParsedDiff(source.diff, source.diffHash),
191 })),
192 ),
57193 ),
58 [selectedSection?.diff, selectedSection?.id, threadKey],
194 [],
195 );
196 const patches = useAtomValue(
197 useMemo(
198 () => Atom.make((get) => queries.map((query) => (query ? get(parsedQuery(query)) : null))),
199 [queries, parsedQuery],
200 ),
201 );
202 const previousPreview = useRef({
203 scope,
204 revision: input.revision,
205 queries: [] as typeof queries,
206 });
207 useEffect(() => {
208 const previous = previousPreview.current;
209 previousPreview.current = { scope, revision: input.revision, queries };
210 for (const query of queries) {
211 if (!query) continue;
212 const changed = previous.scope === scope && previous.revision !== input.revision;
213 const cached =
214 !previous.queries.includes(query) &&
215 registry.get(query)._tag !== "Initial" &&
216 !registry.get(query).waiting;
217 if (changed || cached) registry.refresh(query);
218 }
219 }, [input.revision, queries, registry, scope]);
220 const loadVisibleFile = useCallback(
221 (fileId: string | null, retry = false) => {
222 const index =
223 fileId === null ? 0 : (lazySource?.files?.findIndex((file) => file.path === fileId) ?? -1);
224 if (index < 0) return;
225 setRequested((current) => {
226 const previous = current.scope === scope ? current.indices : [0, 1, 2];
227 const added = [index, index + 1, index + 2].filter((next) => !previous.includes(next));
228 return added.length === 0 ? current : { scope, indices: [...previous, ...added] };
229 });
230 if (retry && patches[index]?._tag === "Failure" && queries[index])
231 registry.refresh(queries[index]);
232 },
233 [lazySource, scope, patches, queries, registry],
234 );
235 const parsedDiff = useMemo<ReviewParsedDiff>(() => {
236 if (!lazySource?.files) return applyReviewDiffMetadata(previewDiff, selectedSection);
237 const files = lazySource.files.map((stat, index) =>
238 getCachedReviewFile(stat, lazySource.diffHash, patches[index]),
239 );
240 return {
241 kind: "files",
242 files,
243 fileCount: files.length,
244 additions: files.reduce((total, file) => total + file.additions, 0),
245 deletions: files.reduce((total, file) => total + file.deletions, 0),
246 notice: null,
247 };
248 }, [lazySource, previewDiff, selectedSection, patches]);
249 const headerDiffSummary = useMemo(
250 () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files),
251 [parsedDiff, selectedSection?.files],
59252 );
60 const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]);
61253 const inlineReviewComments = useMemo(
62254 () => parseReviewInlineComments(draftMessage),
63255 [draftMessage],
......@@ -104,6 +296,8 @@ export function useReviewDiffData(input: {
104296
105297 return {
106298 parsedDiff,
299 loadVisibleFile,
300 isPending: patches.some((patch) => patch?._tag === "Initial" || patch?.waiting),
107301 headerDiffSummary,
108302 nativeReviewDiffData,
109303 pendingReviewCommentCount,
apps/mobile/src/features/review/useReviewSections.ts+8-1
......@@ -1,4 +1,5 @@
11import { useCallback, useEffect, useMemo } from "react";
2import * as DateTime from "effect/DateTime";
23
34import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
45
......@@ -66,13 +67,14 @@ export function useReviewSections(input: {
6667 () =>
6768 buildReviewSectionItems({
6869 checkpoints: readyCheckpoints,
69 gitSections: reviewCache.gitSections,
70 gitSections: diffPreview.data?.sources ?? reviewCache.gitSections,
7071 turnDiffById: reviewCache.turnDiffById,
7172 loadingTurnIds,
7273 loadingGitSections: diffPreview.isPending,
7374 }),
7475 [
7576 diffPreview.isPending,
77 diffPreview.data?.sources,
7678 loadingTurnIds,
7779 readyCheckpoints,
7880 reviewCache.gitSections,
......@@ -173,7 +175,12 @@ export function useReviewSections(input: {
173175
174176 return {
175177 error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error,
178 isSelectedSectionPending:
179 selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending,
176180 loadingGitDiffs: diffPreview.isPending,
181 diffPreviewRevision: diffPreview.data
182 ? DateTime.formatIso(diffPreview.data.generatedAt)
183 : undefined,
177184 loadingTurnIds,
178185 reviewSections,
179186 selectedSection,
apps/server/src/vcs/GitVcsDriverCore.test.ts+288-1
......@@ -15,12 +15,17 @@ import * as Queue from "effect/Queue";
1515import * as Ref from "effect/Ref";
1616import * as Result from "effect/Result";
1717import * as Scope from "effect/Scope";
18import * as Schema from "effect/Schema";
1819import * as Sink from "effect/Sink";
1920import * as Stream from "effect/Stream";
2021import * as TestClock from "effect/testing/TestClock";
2122import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2223
23import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts";
24import {
25 GitCommandError,
26 ReviewDiffPreviewInput,
27 type ReviewDiffFileContentsInput,
28} from "@t3tools/contracts";
2429import { ServerConfig } from "../config.ts";
2530import { gitCommandDuration } from "../observability/Metrics.ts";
2631import {
......@@ -958,6 +963,119 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
958963 });
959964
960965 describe("review diff previews", () => {
966 it.effect("loads repository-relative files from a nested project directory", () =>
967 Effect.gen(function* () {
968 const cwd = yield* makeTmpDir();
969 const { initialBranch } = yield* initRepoWithCommit(cwd);
970 yield* git(cwd, ["checkout", "-b", "feature/nested"]);
971 yield* writeTextFile(cwd, "nested/tracked.txt", "committed\n");
972 yield* git(cwd, ["add", "."]);
973 yield* git(cwd, ["commit", "-m", "nested file"]);
974 yield* writeTextFile(cwd, "nested/tracked.txt", "changed\n");
975 yield* writeTextFile(cwd, "untracked.txt", "new\n");
976 const path = yield* Path.Path;
977 const driver = yield* GitVcsDriver.GitVcsDriver;
978 const nestedCwd = path.join(cwd, "nested");
979 const preview = yield* driver.getReviewDiffPreview({
980 cwd: nestedCwd,
981 baseRef: initialBranch,
982 });
983 assert.equal(
984 preview.sources.find((source) => source.kind === "working-tree")!.files!.length,
985 2,
986 );
987 for (const source of preview.sources) {
988 for (const file of source.files ?? []) {
989 const scoped = yield* driver.getReviewDiffPreview({
990 cwd: nestedCwd,
991 baseRef: initialBranch,
992 file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind },
993 });
994 const patch = scoped.sources.find((item) => item.kind === source.kind)!;
995 assert.deepStrictEqual(patch.files, [file]);
996 assert.include(patch.diff, `b/${file.path}`);
997 }
998 }
999 }),
1000 );
1001
1002 it.effect("reads complete tracked and untracked manifests beyond 1 MB", () =>
1003 Effect.gen(function* () {
1004 const cwd = yield* makeTmpDir();
1005 const { initialBranch } = yield* initRepoWithCommit(cwd);
1006 yield* writeTextFile(cwd, "untracked.txt", "untracked content\n");
1007 const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`);
1008 const stats = paths.map((path) => `1\t0\t${path}\0`).join("");
1009 const untracked = [...paths, "untracked.txt"].join("\0") + "\0";
1010 assert.isAbove(stats.length, 1024 * 1024);
1011 assert.isAbove(untracked.length, 1024 * 1024);
1012 let readLargeUntracked = false;
1013 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
1014 const spawner = ChildProcessSpawner.make((command) => {
1015 if (ChildProcess.isStandardCommand(command)) {
1016 if (
1017 command.args.includes("--numstat") &&
1018 command.args.includes(`${initialBranch}...HEAD`)
1019 ) {
1020 return Effect.succeed(makeSuccessfulHandle(stats));
1021 }
1022 if (command.args.includes("ls-files") && command.args.includes("--others")) {
1023 return Effect.succeed(makeSuccessfulHandle(readLargeUntracked ? untracked : ""));
1024 }
1025 }
1026 return delegate.spawn(command);
1027 });
1028 const driver = yield* makeGitVcsDriverCore().pipe(
1029 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1030 Effect.provide(ServerConfigLayer),
1031 );
1032 const branch = yield* driver.getReviewDiffPreview({
1033 cwd,
1034 baseRef: initialBranch,
1035 });
1036 const files = branch.sources.find((source) => source.kind === "branch-range")!.files!;
1037 assert.equal(files.length, paths.length);
1038 assert.equal(files.at(-1)?.path, paths.at(-1));
1039 assert.equal(
1040 files.reduce((total, file) => total + file.additions, 0),
1041 paths.length,
1042 );
1043 readLargeUntracked = true;
1044 const dirty = yield* driver.getReviewDiffPreview({
1045 cwd,
1046 file: { path: "untracked.txt", previousPath: null, sourceKind: "working-tree" },
1047 });
1048 const source = dirty.sources.find((source) => source.kind === "working-tree")!;
1049 assert.deepStrictEqual(source.files, [
1050 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
1051 ]);
1052 assert.include(source.diff, "+untracked content");
1053 }),
1054 );
1055
1056 it.effect("propagates patch failures instead of reporting an empty complete diff", () =>
1057 Effect.gen(function* () {
1058 const cwd = yield* makeTmpDir();
1059 yield* initRepoWithCommit(cwd);
1060 yield* writeTextFile(cwd, "README.md", "changed\n");
1061 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
1062 const spawner = ChildProcessSpawner.make((command) =>
1063 ChildProcess.isStandardCommand(command) && command.args.includes("--patch")
1064 ? Effect.succeed(makeNonRepositoryHandle())
1065 : delegate.spawn(command),
1066 );
1067 const driver = yield* makeGitVcsDriverCore().pipe(
1068 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1069 Effect.provide(ServerConfigLayer),
1070 );
1071 const result = yield* driver.getReviewDiffPreview({ cwd }).pipe(Effect.result);
1072 assert.isTrue(Result.isFailure(result));
1073 if (Result.isFailure(result)) {
1074 assert.equal(result.failure.operation, "GitVcsDriver.getReviewDiffPreview.patch");
1075 }
1076 }),
1077 );
1078
9611079 it.effect("drops an unterminated path from truncated NUL-separated git output", () =>
9621080 Effect.sync(() => {
9631081 const paths = splitNullSeparatedGitStdoutPaths({
......@@ -1008,6 +1126,14 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
10081126 ignored.sources.find((source) => source.kind === "working-tree")?.diff,
10091127 "",
10101128 );
1129 assert.deepStrictEqual(
1130 ignored.sources.find((source) => source.kind === "working-tree")?.files,
1131 [],
1132 );
1133 assert.deepStrictEqual(
1134 ignored.sources.find((source) => source.kind === "branch-range")?.files,
1135 [],
1136 );
10111137 assert.strictEqual(
10121138 ignored.sources.find((source) => source.kind === "branch-range")?.diff,
10131139 "",
......@@ -1057,6 +1183,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
10571183
10581184 assert.include(diff, "+literal pathspec contents");
10591185 assert.include(diff, "+ordinary contents");
1186 const scoped = yield* driver.getReviewDiffPreview({
1187 cwd,
1188 file: {
1189 path: ":(exclude)after.ts",
1190 previousPath: null,
1191 sourceKind: "working-tree",
1192 },
1193 });
1194 const scopedSource = scoped.sources.find((source) => source.kind === "working-tree")!;
1195 assert.deepStrictEqual(scopedSource.files, [
1196 { path: ":(exclude)after.ts", previousPath: null, additions: 1, deletions: 0 },
1197 ]);
1198 assert.include(scopedSource.diff, "+literal pathspec contents");
1199 assert.notInclude(scopedSource.diff, "ordinary.ts");
10601200 assert.strictEqual(yield* git(cwd, ["ls-files", "--stage"]), indexBefore);
10611201 }),
10621202 );
......@@ -1096,6 +1236,17 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
10961236 .filter((entry) => entry.startsWith("sharedindex."))
10971237 .sort();
10981238
1239 const source = preview.sources.find((candidate) => candidate.kind === "working-tree")!;
1240 assert.deepStrictEqual(source.files, [
1241 { path: "after.ts", previousPath: "before.ts", additions: 1, deletions: 1 },
1242 ]);
1243 const scoped = yield* driver.getReviewDiffPreview({
1244 cwd,
1245 file: { path: "after.ts", previousPath: "before.ts", sourceKind: "working-tree" },
1246 });
1247 const scopedSource = scoped.sources.find((candidate) => candidate.kind === "working-tree")!;
1248 assert.deepStrictEqual(scopedSource.files, source.files);
1249 assert.equal(scopedSource.diff, diff);
10991250 assert.include(diff, "rename from before.ts");
11001251 assert.include(diff, "rename to after.ts");
11011252 assert.include(diff, "-three");
......@@ -1169,6 +1320,142 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
11691320 }),
11701321 );
11711322
1323 it.effect("keeps complete stats for files beyond the combined patch limit", () =>
1324 Effect.gen(function* () {
1325 const cwd = yield* makeTmpDir();
1326 const { initialBranch } = yield* initRepoWithCommit(cwd);
1327 const driver = yield* GitVcsDriver.GitVcsDriver;
1328 const largeContents = "a long line of changed content for the diff preview\n".repeat(4000);
1329 yield* git(cwd, ["checkout", "-b", "feature/large"]);
1330 yield* writeTextFile(cwd, "a-large.txt", largeContents);
1331 yield* writeTextFile(cwd, "z-last.txt", "last file\n");
1332 yield* git(cwd, ["add", "."]);
1333 yield* git(cwd, ["commit", "-m", "large change"]);
1334 yield* writeTextFile(cwd, "a-large.txt", largeContents.replaceAll("changed", "updated"));
1335 yield* writeTextFile(cwd, "z-last.txt", "last file updated\n");
1336 yield* writeTextFile(cwd, "untracked.txt", largeContents);
1337
1338 const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch });
1339 const branch = preview.sources.find((source) => source.kind === "branch-range")!;
1340 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
1341 assert.isTrue(branch.truncated);
1342 assert.isTrue(dirty.truncated);
1343 assert.notInclude(branch.diff, "z-last.txt");
1344 for (const source of [branch, dirty]) {
1345 for (const file of source.files ?? []) {
1346 const individual = yield* driver.getReviewDiffPreview({
1347 cwd,
1348 baseRef: initialBranch,
1349 file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind },
1350 });
1351 const patch = individual.sources.find((candidate) => candidate.kind === source.kind)!;
1352 assert.isFalse(patch.truncated);
1353 assert.deepStrictEqual(patch.files, [file]);
1354 assert.include(patch.diff, `b/${file.path}`);
1355 assert.isEmpty(
1356 individual.sources.find((candidate) => candidate.kind !== source.kind)!.diff,
1357 );
1358 }
1359 }
1360
1361 assert.deepStrictEqual(branch.files, [
1362 { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 },
1363 { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 },
1364 ]);
1365 assert.deepStrictEqual(dirty.files, [
1366 { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 4000 },
1367 { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 },
1368 { path: "z-last.txt", previousPath: null, additions: 1, deletions: 1 },
1369 ]);
1370 }),
1371 );
1372
1373 it.effect("preserves renames, unusual paths, modes, and binary statistics", () =>
1374 Effect.gen(function* () {
1375 const cwd = yield* makeTmpDir();
1376 const { initialBranch } = yield* initRepoWithCommit(cwd);
1377 const driver = yield* GitVcsDriver.GitVcsDriver;
1378 yield* writeTextFile(cwd, "mode-only.sh", "echo unchanged\n");
1379 yield* git(cwd, ["add", "mode-only.sh"]);
1380 yield* git(cwd, ["commit", "-m", "add executable candidate"]);
1381 yield* git(cwd, ["checkout", "-b", "feature/paths"]);
1382 yield* git(cwd, ["mv", "README.md", "renamed.md"]);
1383 yield* writeTextFile(cwd, "[literal].txt", "literal\n");
1384 yield* writeTextFile(cwd, " leading.txt", "whitespace path\n");
1385 yield* writeTextFile(cwd, "l.txt", "other\n");
1386 yield* writeTextFile(cwd, "binary.dat", "binary\0data");
1387 if ((yield* HostProcessPlatform) !== "win32") {
1388 yield* writeTextFile(cwd, "tab\tand\nnewline.txt", "unusual path\n");
1389 }
1390 yield* git(cwd, ["add", "."]);
1391 yield* git(cwd, ["update-index", "--chmod=+x", "mode-only.sh"]);
1392 yield* git(cwd, ["commit", "-m", "rename and add files"]);
1393 const preview = yield* driver.getReviewDiffPreview({
1394 cwd,
1395 baseRef: initialBranch,
1396 });
1397 const branch = preview.sources.find((source) => source.kind === "branch-range")!;
1398 for (const path of ["renamed.md", "[literal].txt", " leading.txt", "mode-only.sh"]) {
1399 const stat = branch.files!.find((file) => file.path === path)!;
1400 const request = yield* Schema.decodeEffect(ReviewDiffPreviewInput)({
1401 cwd,
1402 baseRef: initialBranch,
1403 file: { path, previousPath: stat.previousPath, sourceKind: "branch-range" },
1404 });
1405 const result = yield* driver.getReviewDiffPreview(request);
1406 const scoped = result.sources.find((source) => source.kind === "branch-range")!;
1407 assert.deepStrictEqual(scoped.files, [stat]);
1408 assert.notInclude(scoped.diff, "b/l.txt");
1409 if (path === "renamed.md") assert.include(scoped.diff, "rename from README.md");
1410 if (path === "mode-only.sh") {
1411 assert.include(scoped.diff, "old mode 100644");
1412 assert.include(scoped.diff, "new mode 100755");
1413 }
1414 }
1415 assert.include(branch.diff, "rename from README.md");
1416 assert.include(branch.diff, "rename to renamed.md");
1417 assert.deepInclude(branch.files ?? [], {
1418 path: "renamed.md",
1419 previousPath: "README.md",
1420 additions: 0,
1421 deletions: 0,
1422 });
1423 assert.deepInclude(branch.files ?? [], {
1424 path: "binary.dat",
1425 previousPath: null,
1426 additions: 0,
1427 deletions: 0,
1428 });
1429 if ((yield* HostProcessPlatform) !== "win32") {
1430 assert.deepInclude(branch.files ?? [], {
1431 path: "tab\tand\nnewline.txt",
1432 previousPath: null,
1433 additions: 1,
1434 deletions: 0,
1435 });
1436 }
1437 }),
1438 );
1439
1440 it.effect("reports staged and untracked changes before the first commit", () =>
1441 Effect.gen(function* () {
1442 const cwd = yield* makeTmpDir();
1443 yield* git(cwd, ["init"]);
1444 yield* writeTextFile(cwd, "staged.txt", "staged\n");
1445 yield* git(cwd, ["add", "staged.txt"]);
1446 yield* writeTextFile(cwd, "untracked.txt", "untracked\n");
1447 const driver = yield* GitVcsDriver.GitVcsDriver;
1448 const preview = yield* driver.getReviewDiffPreview({ cwd });
1449 const dirty = preview.sources.find((source) => source.kind === "working-tree")!;
1450 assert.deepStrictEqual(dirty.files, [
1451 { path: "staged.txt", previousPath: null, additions: 1, deletions: 0 },
1452 { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 },
1453 ]);
1454 assert.include(dirty.diff, "b/staged.txt");
1455 assert.include(dirty.diff, "b/untracked.txt");
1456 }),
1457 );
1458
11721459 it.effect("loads full file contents for working-tree diff expansion", () =>
11731460 Effect.gen(function* () {
11741461 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+176-223
......@@ -1,4 +1,3 @@
1import * as Arr from "effect/Array";
21import * as Cache from "effect/Cache";
32import * as Data from "effect/Data";
43import * as Crypto from "effect/Crypto";
......@@ -23,10 +22,12 @@ import {
2322 GitCommandError,
2423 type ReviewDiffFileContentsInput,
2524 type ReviewDiffPreviewInput,
25 type ReviewDiffFileStat,
2626 type ReviewDiffPreviewSource,
2727 type VcsRef,
2828} from "@t3tools/contracts";
2929import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git";
30import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3031import { compactTraceAttributes } from "@t3tools/shared/observability";
3132import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3233import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
......@@ -52,13 +53,12 @@ const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
5253const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
5354const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
5455const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
55const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;
56const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
5657const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
5758// Patches the clients render are parsed against git's default a/ and b/ path
5859// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would
5960// otherwise leak into the patch and leave every parsed file unnamed.
6061export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const;
61const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;
6262const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
6363const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
6464
......@@ -192,6 +192,26 @@ function parseNumstatEntries(
192192 return entries;
193193}
194194
195// -z preserves tabs/newlines in paths and gives renames two separate path fields.
196function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] {
197 const fields = stdout.split("\0");
198 const files: ReviewDiffFileStat[] = [];
199 for (let index = 0; index < fields.length; index++) {
200 const field = fields[index]!;
201 const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(field);
202 if (!match) continue;
203 const previousPath = match[3] === "" ? fields[++index]! : null;
204 const path = previousPath !== null ? fields[++index]! : match[3]!;
205 files.push({
206 path,
207 previousPath,
208 additions: match[1] === "-" ? 0 : Number(match[1]),
209 deletions: match[2] === "-" ? 0 : Number(match[2]),
210 });
211 }
212 return files;
213}
214
195215function parsePorcelainPath(line: string): string | null {
196216 if (line.startsWith("? ") || line.startsWith("! ")) {
197217 const simple = line.slice(2).trim();
......@@ -2258,102 +2278,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22582278 };
22592279 });
22602280
2261 const readUntrackedReviewDiffs = Effect.fn("readUntrackedReviewDiffs")(function* (cwd: string) {
2262 const untrackedResult = yield* executeGit(
2263 "GitVcsDriver.readUntrackedReviewDiffs.list",
2264 cwd,
2265 ["ls-files", "--others", "--exclude-standard", "-z"],
2266 {
2267 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
2268 appendTruncationMarker: true,
2269 },
2270 );
2271 const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult);
2272 if (untrackedPaths.length === 0) {
2273 return { diff: "", truncated: untrackedResult.stdoutTruncated };
2274 }
2275
2276 const diffs = yield* Effect.forEach(
2277 untrackedPaths,
2278 (relativePath) =>
2279 executeGit(
2280 "GitVcsDriver.readUntrackedReviewDiffs.diff",
2281 cwd,
2282 [
2283 "diff",
2284 "--no-index",
2285 "--patch",
2286 "--no-color",
2287 "--no-ext-diff",
2288 "--no-textconv",
2289 "--minimal",
2290 ...PATCH_RENDER_PREFIX_ARGS,
2291 "--",
2292 "/dev/null",
2293 relativePath,
2294 ],
2295 {
2296 allowNonZeroExit: true,
2297 maxOutputBytes: REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES,
2298 appendTruncationMarker: true,
2299 },
2300 ),
2301 { concurrency: 4 },
2302 );
2303
2304 return {
2305 diff: Arr.filterMap(diffs, (result) =>
2306 result.stdout.trim().length > 0 ? Result.succeed(result.stdout) : Result.failVoid,
2307 ).join("\n"),
2308 truncated: untrackedResult.stdoutTruncated || diffs.some((result) => result.stdoutTruncated),
2309 };
2310 });
2311
2312 const readTrackedReviewDiff = Effect.fn("readTrackedReviewDiff")(function* (
2313 cwd: string,
2314 ignoreWhitespace: boolean | undefined,
2315 ) {
2316 const result = yield* executeGit(
2317 "GitVcsDriver.readTrackedReviewDiff",
2318 cwd,
2319 [
2320 "diff",
2321 "--patch",
2322 "--no-color",
2323 "--no-ext-diff",
2324 "--no-textconv",
2325 "--minimal",
2326 ...PATCH_RENDER_PREFIX_ARGS,
2327 "--find-renames",
2328 ...(ignoreWhitespace ? ["--ignore-all-space"] : []),
2329 "HEAD",
2330 "--",
2331 ],
2332 {
2333 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
2334 appendTruncationMarker: true,
2335 },
2336 );
2337 return { diff: result.stdout, truncated: result.stdoutTruncated };
2338 });
2339
2340 const readUnifiedWorkingTreeReviewDiff = Effect.fn("readUnifiedWorkingTreeReviewDiff")(function* (
2281 // Use the same temporary index for patch and statistics so unstaged renames agree.
2282 const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* (
23412283 cwd: string,
23422284 untrackedPaths: ReadonlyArray<string>,
2343 pathsTruncated: boolean,
2344 ignoreWhitespace: boolean | undefined,
23452285 ) {
23462286 const [stagedDeletionsStdout, indexValue] = yield* Effect.all(
23472287 [
2348 runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions", cwd, [
2349 "diff",
2350 "--cached",
2351 "--name-only",
2352 "--diff-filter=D",
2353 "-z",
2354 "HEAD",
2355 "--",
2356 ]),
2288 runGitStdoutWithOptions(
2289 "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.stagedDeletions",
2290 cwd,
2291 ["diff", "--cached", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"],
2292 { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES },
2293 ),
23572294 runGitStdout("GitVcsDriver.readUnifiedWorkingTreeReviewDiff.indexPath", cwd, [
23582295 "rev-parse",
23592296 "--git-path",
......@@ -2364,10 +2301,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23642301 );
23652302 const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean));
23662303 const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath));
2367 if (pathsToAdd.length === 0) {
2368 const tracked = yield* readTrackedReviewDiff(cwd, ignoreWhitespace);
2369 return { ...tracked, truncated: pathsTruncated || tracked.truncated };
2370 }
2304 if (pathsToAdd.length === 0) return undefined;
23712305
23722306 const indexPath = path.isAbsolute(indexValue.trim())
23732307 ? indexValue.trim()
......@@ -2375,7 +2309,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23752309 const tempIndexPath = yield* fileSystem.makeTempFileScoped({
23762310 prefix: `t3code-review-index-${process.pid}-`,
23772311 });
2378 yield* fileSystem.copyFile(indexPath, tempIndexPath);
2312 const indexExists = yield* fileSystem.exists(indexPath);
2313 if (indexExists) yield* fileSystem.copyFile(indexPath, tempIndexPath);
23792314 const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv;
23802315 const tempIndexConfig = [
23812316 "-c",
......@@ -2383,6 +2318,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23832318 "-c",
23842319 "splitIndex.sharedIndexExpire=never",
23852320 ];
2321 if (!indexExists) {
2322 yield* executeGit("GitVcsDriver.review.emptyIndex", cwd, ["read-tree", "--empty"], { env });
2323 }
23862324 yield* executeGit(
23872325 "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.expandSplitIndex",
23882326 cwd,
......@@ -2402,86 +2340,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24022340 ],
24032341 { env, stdin: `${pathsToAdd.join("\0")}\0` },
24042342 );
2405 const result = yield* executeGit(
2406 "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.diff",
2407 cwd,
2408 [
2409 ...tempIndexConfig,
2410 "diff",
2411 "--patch",
2412 "--no-color",
2413 "--no-ext-diff",
2414 "--no-textconv",
2415 "--minimal",
2416 ...PATCH_RENDER_PREFIX_ARGS,
2417 "--find-renames",
2418 ...(ignoreWhitespace ? ["--ignore-all-space"] : []),
2419 "HEAD",
2420 "--",
2421 ],
2422 {
2423 env,
2424 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
2425 appendTruncationMarker: true,
2426 },
2427 );
2428 return { diff: result.stdout, truncated: pathsTruncated || result.stdoutTruncated };
2429 });
2430
2431 const readWorkingTreeReviewDiff = Effect.fn("readWorkingTreeReviewDiff")(function* (
2432 cwd: string,
2433 ignoreWhitespace: boolean | undefined,
2434 ) {
2435 const untrackedResult = yield* executeGit(
2436 "GitVcsDriver.readWorkingTreeReviewDiff.listUntracked",
2437 cwd,
2438 ["ls-files", "--others", "--exclude-standard", "-z"],
2439 {
2440 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
2441 appendTruncationMarker: true,
2442 },
2443 ).pipe(Effect.option);
2444 if (untrackedResult._tag === "None") {
2445 return yield* readTrackedReviewDiff(cwd, ignoreWhitespace);
2446 }
2447 const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult.value);
2448 if (untrackedPaths.length === 0) {
2449 const tracked = yield* readTrackedReviewDiff(cwd, ignoreWhitespace);
2450 return { ...tracked, truncated: untrackedResult.value.stdoutTruncated || tracked.truncated };
2451 }
2452
2453 return yield* readUnifiedWorkingTreeReviewDiff(
2454 cwd,
2455 untrackedPaths,
2456 untrackedResult.value.stdoutTruncated,
2457 ignoreWhitespace,
2458 ).pipe(
2459 Effect.scoped,
2460 Effect.catch(() =>
2461 Effect.all([
2462 readTrackedReviewDiff(cwd, ignoreWhitespace).pipe(
2463 Effect.orElseSucceed(() => ({ diff: "", truncated: false })),
2464 ),
2465 readUntrackedReviewDiffs(cwd).pipe(
2466 Effect.orElseSucceed(() => ({ diff: "", truncated: false })),
2467 ),
2468 ]).pipe(
2469 Effect.map(([tracked, untracked]) => ({
2470 diff: [tracked.diff.trimEnd(), untracked.diff.trimEnd()]
2471 .filter((diff) => diff.length > 0)
2472 .join("\n"),
2473 truncated: tracked.truncated || untracked.truncated,
2474 })),
2475 ),
2476 ),
2477 );
2343 return env;
24782344 });
24792345
24802346 const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* (
24812347 input: ReviewDiffPreviewInput,
24822348 ) {
2483 const details = yield* statusDetailsLocal(input.cwd);
2484 if (!details.isRepo) {
2349 const pathArgs = input.file
2350 ? [input.file.path, ...(input.file.previousPath ? [input.file.previousPath] : [])].map(
2351 (path) => `:(top,literal)${path}`,
2352 )
2353 : [];
2354 const patchLimit = input.file
2355 ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES
2356 : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES;
2357 const repository = yield* resolveRepositoryPathsUncached(input.cwd).pipe(
2358 Effect.catchTags({
2359 GitCommandError: (error) =>
2360 isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
2361 }),
2362 );
2363 if (!repository?.worktreeRoot) {
24852364 return {
24862365 cwd: input.cwd,
24872366 generatedAt: yield* DateTime.now,
......@@ -2489,71 +2368,143 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24892368 };
24902369 }
24912370
2492 const branch = details.branch;
2371 const cwd = repository.worktreeRoot;
2372 const branch = repository.currentBranch;
24932373 const baseRef =
24942374 input.baseRef ??
24952375 (branch
2496 ? yield* resolveBaseBranchForNoUpstream(input.cwd, branch).pipe(
2497 Effect.orElseSucceed(() => null),
2498 )
2376 ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null))
24992377 : null);
25002378
2501 const dirtyResult = yield* readWorkingTreeReviewDiff(input.cwd, input.ignoreWhitespace).pipe(
2502 Effect.orElseSucceed(() => ({
2503 diff: "",
2504 truncated: false,
2505 })),
2379 const diffArgs = [
2380 "diff",
2381 "--find-renames",
2382 "--no-color",
2383 "--no-ext-diff",
2384 "--no-textconv",
2385 "--minimal",
2386 ...PATCH_RENDER_PREFIX_ARGS,
2387 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
2388 ];
2389 const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* (
2390 ref: string,
2391 env?: NodeJS.ProcessEnv,
2392 ) {
2393 const args = [...diffArgs, "--numstat", "-z"];
2394 const result = yield* executeGit(
2395 "GitVcsDriver.getReviewDiffPreview.stat",
2396 cwd,
2397 [...args, ref, "--", ...pathArgs],
2398 { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env },
2399 );
2400 if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) };
2401 if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) {
2402 const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [
2403 "hash-object",
2404 "-t",
2405 "tree",
2406 (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null",
2407 ])).trim();
2408 const stdout = yield* runGitStdoutWithOptions(
2409 "GitVcsDriver.getReviewDiffPreview.unbornStat",
2410 cwd,
2411 [...args, emptyTree, "--", ...pathArgs],
2412 { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env },
2413 );
2414 return { ref: emptyTree, files: parseReviewNumstat(stdout) };
2415 }
2416 return yield* new GitCommandError({
2417 operation: "GitVcsDriver.getReviewDiffPreview.stat",
2418 cwd,
2419 command: "git diff --numstat",
2420 detail: "Could not read complete diff statistics.",
2421 exitCode: result.exitCode,
2422 });
2423 });
2424 const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* (
2425 ref: string | null,
2426 env?: NodeJS.ProcessEnv,
2427 ) {
2428 if (ref === null) return { stdout: "", stdoutTruncated: false, files: [] };
2429 const stat = yield* readStats(ref, env);
2430 if (stat.files.length === 0) return { stdout: "", stdoutTruncated: false, files: [] };
2431 const patch = yield* executeGit(
2432 "GitVcsDriver.getReviewDiffPreview.patch",
2433 cwd,
2434 [...diffArgs, "--patch", stat.ref, "--", ...pathArgs],
2435 { maxOutputBytes: patchLimit, appendTruncationMarker: true, env },
2436 );
2437 return { ...patch, files: stat.files };
2438 });
2439 const readDirty = Effect.gen(function* () {
2440 if (input.file?.sourceKind === "branch-range") return yield* readTrackedDiff(null);
2441 const untracked = yield* executeGit(
2442 "GitVcsDriver.review.listUntracked",
2443 cwd,
2444 ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs],
2445 { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES },
2446 ).pipe(
2447 Effect.catchIf(
2448 (error) => error.outputLength === undefined,
2449 () => Effect.succeed(null),
2450 ),
2451 );
2452 if (untracked === null) {
2453 const tracked = yield* readTrackedDiff("HEAD");
2454 return { ...tracked, files: undefined, stdoutTruncated: true };
2455 }
2456 const paths = splitNullSeparatedGitStdoutPaths(untracked).filter(
2457 (candidate) => !input.file || candidate === input.file.path,
2458 );
2459 if (paths.length === 0) return yield* readTrackedDiff("HEAD");
2460 const env = yield* prepareReviewIndex(cwd, paths).pipe(
2461 Effect.catchTags({
2462 PlatformError: (cause) =>
2463 Effect.fail(
2464 new GitCommandError({
2465 operation: "GitVcsDriver.prepareReviewIndex",
2466 cwd,
2467 command: "git diff",
2468 detail: "Could not prepare the review index.",
2469 cause,
2470 }),
2471 ),
2472 }),
2473 );
2474 return yield* readTrackedDiff("HEAD", env);
2475 }).pipe(Effect.scoped);
2476 const [dirtyTrackedResult, baseResult] = yield* Effect.all(
2477 [
2478 readDirty,
2479 readTrackedDiff(
2480 baseRef && branch && input.file?.sourceKind !== "working-tree"
2481 ? `${baseRef}...HEAD`
2482 : null,
2483 ),
2484 ],
2485 { concurrency: 2 },
25062486 );
2507 const dirtyDiff = dirtyResult.diff;
2508
2509 const baseResult =
2510 baseRef && branch
2511 ? yield* executeGit(
2512 "GitVcsDriver.getReviewDiffPreview.base",
2513 input.cwd,
2514 [
2515 "diff",
2516 "--patch",
2517 "--no-color",
2518 "--no-ext-diff",
2519 "--no-textconv",
2520 "--minimal",
2521 ...PATCH_RENDER_PREFIX_ARGS,
2522 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
2523 `${baseRef}...HEAD`,
2524 ],
2525 {
2526 maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES,
2527 appendTruncationMarker: true,
2528 },
2529 ).pipe(
2530 Effect.orElseSucceed(() => ({
2531 exitCode: 0,
2532 stdout: "",
2533 stderr: "",
2534 stdoutTruncated: false,
2535 stderrTruncated: false,
2536 })),
2537 )
2538 : null;
2539 const baseDiff = baseResult?.stdout ?? "";
2540 const hashDiff = (diff: string) =>
2541 crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe(
2487 const dirtyFiles = dirtyTrackedResult.files;
2488 const baseFiles = baseResult.files;
2489 const dirtyDiff = dirtyTrackedResult.stdout;
2490 const baseDiff = baseResult.stdout;
2491 const hashDiff = (diff: string, files: ReadonlyArray<ReviewDiffFileStat>) =>
2492 crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe(
25422493 Effect.map(Encoding.encodeHex),
25432494 Effect.mapError(
25442495 (cause) =>
25452496 new GitCommandError({
25462497 operation: "GitVcsDriver.getReviewDiffPreview.hash",
25472498 command: "crypto.digest SHA-256",
2548 cwd: input.cwd,
2499 cwd,
25492500 detail: "Failed to hash review diff.",
25502501 cause,
25512502 }),
25522503 ),
25532504 );
25542505 const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([
2555 hashDiff(dirtyDiff),
2556 hashDiff(baseDiff),
2506 hashDiff(dirtyDiff, dirtyFiles ?? []),
2507 hashDiff(baseDiff, baseFiles),
25572508 ]);
25582509
25592510 const sources: ReviewDiffPreviewSource[] = [
......@@ -2564,8 +2515,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
25642515 baseRef: "HEAD",
25652516 headRef: null,
25662517 diff: dirtyDiff,
2518 ...(dirtyFiles === undefined ? {} : { files: dirtyFiles }),
25672519 diffHash: dirtyDiffHash,
2568 truncated: dirtyResult.truncated,
2520 truncated: dirtyTrackedResult.stdoutTruncated,
25692521 },
25702522 {
25712523 id: "branch-range",
......@@ -2574,8 +2526,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
25742526 baseRef,
25752527 headRef: branch ?? "HEAD",
25762528 diff: baseDiff,
2529 files: baseFiles,
25772530 diffHash: baseDiffHash,
2578 truncated: baseResult?.stdoutTruncated ?? false,
2531 truncated: baseResult.stdoutTruncated,
25792532 },
25802533 ];
25812534
apps/web/src/components/DiffPanel.tsx+212-84
......@@ -1,6 +1,6 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
22import { useAtomValue } from "@effect/atom-react";
3import type { FileDiffContentsLoader } from "@pierre/diffs";
3import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs";
44import { useParams } from "@tanstack/react-router";
55import {
66 isAtomCommandInterrupted,
......@@ -23,6 +23,7 @@ import {
2323 TextWrapIcon,
2424} from "lucide-react";
2525import * as Schema from "effect/Schema";
26import * as DateTime from "effect/DateTime";
2627import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2728import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
2829import { useOpenInPreferredEditor } from "../editorPreferences";
......@@ -86,9 +87,29 @@ import { vcsEnvironment } from "../state/vcs";
8687import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices";
8788import { createGitDiffFileContentsLoader } from "../lib/diffFileContents";
8889
90import { useReviewFilePatches } from "./diffs/useReviewFilePatches";
91import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary";
92import { DiffFileStatus } from "./diffs/DiffFileStatus";
93
8994type DiffThemeType = "light" | "dark";
9095const AUTOMATIC_BASE_REF = "__automatic_base_ref__";
9196const DIFF_FILE_TREE_STORAGE_KEY = "t3code.diffFileTreeOpen";
97const fileEntryCache = new WeakMap<
98 FileDiffMetadata,
99 { fileDiff: FileDiffMetadata; fileKey: string; fileVersion: number }
100>();
101
102function getCachedFileEntry(fileDiff: FileDiffMetadata) {
103 const cached = fileEntryCache.get(fileDiff);
104 if (cached) return cached;
105 const entry = {
106 fileDiff,
107 fileKey: buildFileDiffIdentityKey(fileDiff),
108 fileVersion: buildFileDiffContentVersion(fileDiff),
109 };
110 fileEntryCache.set(fileDiff, entry);
111 return entry;
112}
92113
93114interface CollapsedDiffFilesState {
94115 readonly scopeKey: string | null;
......@@ -284,30 +305,18 @@ export default function DiffPanel({
284305 const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd
285306 ? fallbackBranchDiffPreview
286307 : primaryBranchDiffPreview;
287 const refreshBranchDiffPreview = branchDiffPreview.refresh;
288308 const canRefreshGitDiff =
289309 isGitRepo && selectedTurnId === null && activeThread != null && activeCwd != null;
290310 const activeThreadRefreshKey = routeThreadRef
291311 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}`
292312 : null;
293313
294 useEffect(() => {
295 if (!canRefreshGitDiff) return;
296 const refreshOnFocus = () => refreshBranchDiffPreview();
297 window.addEventListener("focus", refreshOnFocus);
298 return () => window.removeEventListener("focus", refreshOnFocus);
299 }, [canRefreshGitDiff, refreshBranchDiffPreview]);
300
301 useWorkspaceMutationRefresh({
302 enabled: canRefreshGitDiff,
303 mutationId: workspaceMutationId,
304 refresh: refreshBranchDiffPreview,
305 resourceKey: `diff:${activeThreadRefreshKey ?? ""}`,
306 });
307
308314 const selectedGitSource = branchDiffPreview.data?.sources.find(
309315 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
310316 );
317 const refreshPreviewQuery = branchDiffPreview.refresh;
318 const refreshDiffFromUserAction = refreshPreviewQuery;
319
311320 const currentLoadDiffFiles = useMemo<FileDiffContentsLoader | undefined>(() => {
312321 const preview = branchDiffPreview.data;
313322 if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) {
......@@ -394,31 +403,64 @@ export default function DiffPanel({
394403 const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error;
395404 const hasResolvedPatch = typeof selectedPatch === "string";
396405 const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0;
406 const lazySource =
407 !selectedTurn && selectedGitSource?.truncated && selectedGitSource.files
408 ? selectedGitSource
409 : null;
397410 const renderablePatch = useMemo(
398411 () =>
399 getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, {
400 compactPartialHunkOffsets: selectedTurnId === null,
401 }),
402 [resolvedTheme, selectedPatch, selectedTurnId],
412 lazySource
413 ? null
414 : getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, {
415 compactPartialHunkOffsets: selectedTurnId === null,
416 }),
417 [lazySource, resolvedTheme, selectedPatch, selectedTurnId],
403418 );
404 const renderableFiles = useMemo(() => {
405 if (!renderablePatch || renderablePatch.kind !== "files") {
406 return [];
407 }
408 return renderablePatch.files.toSorted((left, right) =>
409 resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, {
410 numeric: true,
411 sensitivity: "base",
412 }),
413 );
414 }, [renderablePatch]);
419 const fileStats = useMemo(
420 () => new Map(lazySource?.files?.map((file) => [file.path, file])),
421 [lazySource?.files],
422 );
423 const {
424 scope: filePatchScope,
425 isPending: areFilePatchesPending,
426 fileStates,
427 retry,
428 requestFile,
429 readyFilePaths,
430 renderableFiles,
431 settledFileCount,
432 loadNextFiles,
433 } = useReviewFilePatches({
434 environmentId: activeThread?.environmentId,
435 cwd: branchDiffPreview.data?.cwd,
436 source: lazySource,
437 baseRef: lazySource?.baseRef ?? selectedBaseRef,
438 ignoreWhitespace: diffIgnoreWhitespace,
439 theme: resolvedTheme,
440 revision: branchDiffPreview.data
441 ? DateTime.formatIso(branchDiffPreview.data.generatedAt)
442 : undefined,
443 preview: renderablePatch,
444 });
445 const refreshBranchDiffPreview = refreshPreviewQuery;
446
447 useEffect(() => {
448 if (!canRefreshGitDiff) return;
449 const refreshOnFocus = () => refreshBranchDiffPreview();
450 window.addEventListener("focus", refreshOnFocus);
451 return () => window.removeEventListener("focus", refreshOnFocus);
452 }, [canRefreshGitDiff, refreshBranchDiffPreview]);
453
454 useWorkspaceMutationRefresh({
455 enabled: canRefreshGitDiff,
456 mutationId: workspaceMutationId,
457 refresh: refreshBranchDiffPreview,
458 resourceKey: `diff:${activeThreadRefreshKey ?? ""}`,
459 });
460
461 const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending;
415462 const renderableFileEntries = useMemo(
416 () =>
417 renderableFiles.map((fileDiff) => ({
418 fileDiff,
419 fileKey: buildFileDiffIdentityKey(fileDiff),
420 fileVersion: buildFileDiffContentVersion(fileDiff),
421 })),
463 () => renderableFiles.map(getCachedFileEntry),
422464 [renderableFiles],
423465 );
424466 const defaultCollapsedDiffFileKeys = useMemo(
......@@ -432,22 +474,51 @@ export default function DiffPanel({
432474 collapsedDiffFiles.scopeKey === collapseScopeKey
433475 ? collapsedDiffFiles.fileKeys
434476 : defaultCollapsedDiffFileKeys;
477 const renderLoadingBoundary = useCallback(
478 () =>
479 settledFileCount < renderableFiles.length ? (
480 <DiffFileLoadingBoundary
481 load={loadNextFiles}
482 count={renderableFiles.length - settledFileCount}
483 />
484 ) : null,
485 [settledFileCount, renderableFiles.length, loadNextFiles],
486 );
435487 const codeViewFiles = useMemo(
436488 () =>
437 renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => {
438 return {
439 fileDiff,
440 filePath: resolveFileDiffPath(fileDiff),
441 fileKey,
442 fileVersion,
443 collapsed: collapsedDiffFileKeys.has(fileKey),
444 };
445 }),
446 [collapsedDiffFileKeys, renderableFileEntries],
489 renderableFileEntries
490 .filter(({ fileDiff }) => !lazySource || readyFilePaths.has(resolveFileDiffPath(fileDiff)))
491 .map(({ fileDiff, fileKey, fileVersion }) => {
492 return {
493 fileDiff,
494 filePath: resolveFileDiffPath(fileDiff),
495 fileKey,
496 fileVersion,
497 // Header-only placeholders use the viewer's collapsed geometry until their patch arrives.
498 collapsed:
499 collapsedDiffFileKeys.has(fileKey) ||
500 fileDiff.cacheKey?.endsWith(":pending") === true,
501 };
502 }),
503 [collapsedDiffFileKeys, renderableFileEntries, lazySource, readyFilePaths],
504 );
505 const diffFileKeys = useMemo(
506 () => renderableFileEntries.map((file) => file.fileKey),
507 [renderableFileEntries],
447508 );
448 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
449509 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
450 const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);
510 const diffLineStat = useMemo(() => {
511 if (!selectedTurn && selectedGitSource?.files) {
512 return selectedGitSource.files.reduce(
513 (total, file) => ({
514 additions: total.additions + file.additions,
515 deletions: total.deletions + file.deletions,
516 }),
517 { additions: 0, deletions: 0 },
518 );
519 }
520 return getDiffLineStat(renderableFiles);
521 }, [renderableFiles, selectedGitSource, selectedTurn]);
451522 const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]);
452523 const selectedDiffFileKey = selectedFilePath
453524 ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null)
......@@ -462,25 +533,54 @@ export default function DiffPanel({
462533 () => ({ collapseScopeKey, diffSelection }),
463534 [collapseScopeKey, diffSelection],
464535 );
465 const requestTreeReveal = useCodeViewFileReveal(codeView, treeRevealScope);
536 const requestTreeReveal = useCodeViewFileReveal(
537 codeView,
538 treeRevealScope,
539 codeViewFiles.map((file) => file.fileKey),
540 );
466541 const revealDiffFile = useCallback(
467542 (filePath: string) => {
468 const file = codeViewFiles.find((candidate) => candidate.filePath === filePath);
543 const index = renderableFileEntries.findIndex(
544 (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath,
545 );
546 const file = renderableFileEntries[index];
469547 if (!file) return;
470 if (file.collapsed) {
471 setCollapsedDiffFiles((current) => {
472 const next = new Set(
473 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,
474 );
475 next.delete(file.fileKey);
476 return { scopeKey: collapseScopeKey, fileKeys: next };
477 });
548 setCollapsedDiffFiles((current) => {
549 const next = new Set(
550 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,
551 );
552 next.delete(file.fileKey);
553 return { scopeKey: collapseScopeKey, fileKeys: next };
554 });
555 if (lazySource && index >= settledFileCount) {
556 requestFile(index);
478557 }
479558 requestTreeReveal(file.fileKey);
480559 },
481 [codeViewFiles, collapseScopeKey, defaultCollapsedDiffFileKeys, requestTreeReveal],
560 [
561 renderableFileEntries,
562 collapseScopeKey,
563 defaultCollapsedDiffFileKeys,
564 requestTreeReveal,
565 lazySource,
566 settledFileCount,
567 requestFile,
568 ],
482569 );
483570
571 const externalRevealRef = useRef<{ cache: string; key: string } | null>(null);
572 useEffect(() => {
573 if (!lazySource || !selectedFilePath) return;
574 const key = `${selectedFilePath}:${selectedFileRevealRequestId}`;
575 if (
576 externalRevealRef.current?.cache === filePatchScope &&
577 externalRevealRef.current.key === key
578 )
579 return;
580 externalRevealRef.current = { cache: filePatchScope, key };
581 revealDiffFile(selectedFilePath);
582 }, [lazySource, selectedFilePath, selectedFileRevealRequestId, filePatchScope, revealDiffFile]);
583
484584 const openDiffFile = useCallback(
485585 (filePath: string) => {
486586 openDiffFilePrimaryAction({
......@@ -754,14 +854,14 @@ export default function DiffPanel({
754854 )}
755855 </div>
756856 <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
757 {codeViewFiles.length > 0 && (
857 {codeViewFiles.length > 0 || (!selectedTurn && selectedGitSource?.files?.length) ? (
758858 <DiffStatLabel
759859 additions={diffLineStat.additions}
760860 deletions={diffLineStat.deletions}
761861 className="mr-1 text-[11px]"
762862 layout="inline"
763863 />
764 )}
864 ) : null}
765865 {canRefreshGitDiff && (
766866 <Tooltip>
767867 <TooltipTrigger
......@@ -770,19 +870,19 @@ export default function DiffPanel({
770870 type="button"
771871 size="icon-sm"
772872 variant="ghost"
773 aria-label={branchDiffPreview.isPending ? "Refreshing diff" : "Refresh diff"}
774 onClick={refreshBranchDiffPreview}
873 aria-label={isRefreshingDiff ? "Refreshing diff" : "Refresh diff"}
874 onClick={refreshDiffFromUserAction}
775875 />
776876 }
777877 >
778 <RefreshIcon className="size-3.5" refreshing={branchDiffPreview.isPending} />
878 <RefreshIcon className="size-3.5" refreshing={isRefreshingDiff} />
779879 </TooltipTrigger>
780880 <TooltipPopup side="top">
781 {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"}
881 {isRefreshingDiff ? "Refreshing diff…" : "Refresh diff"}
782882 </TooltipPopup>
783883 </Tooltip>
784884 )}
785 {codeViewFiles.length > 0 && (
885 {diffFileKeys.length > 0 && (
786886 <Tooltip>
787887 <TooltipTrigger
788888 render={
......@@ -867,7 +967,7 @@ export default function DiffPanel({
867967 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
868968 </TooltipPopup>
869969 </Tooltip>
870 {codeViewFiles.length > 0 && (
970 {diffFileKeys.length > 0 && (
871971 <Tooltip>
872972 <TooltipTrigger
873973 render={
......@@ -908,10 +1008,10 @@ export default function DiffPanel({
9081008 ) : (
9091009 <>
9101010 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
911 {isSelectedPatchTruncated && (
1011 {isSelectedPatchTruncated && !lazySource && (
9121012 <p className="shrink-0 border-b border-border/70 bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground">
913 This diff was truncated because it exceeded the preview limit. The changes shown are
914 incomplete.
1013 This preview exceeds the size limit. Changes shown are incomplete.
1014 {selectedGitSource?.files ? " Totals include all changes." : ""}
9151015 </p>
9161016 )}
9171017 {selectedPatchError && !renderablePatch && (
......@@ -919,7 +1019,7 @@ export default function DiffPanel({
9191019 <p className="mb-2 text-[11px] text-error/80">{selectedPatchError}</p>
9201020 </div>
9211021 )}
922 {!renderablePatch ? (
1022 {!renderablePatch && !lazySource ? (
9231023 isLoadingSelectedPatch ? (
9241024 <DiffPanelLoadingState
9251025 label={
......@@ -939,7 +1039,7 @@ export default function DiffPanel({
9391039 </p>
9401040 </div>
9411041 )
942 ) : renderablePatch.kind === "files" ? (
1042 ) : lazySource || renderablePatch?.kind === "files" ? (
9431043 <div className="flex min-h-0 flex-1 overflow-hidden">
9441044 <div
9451045 className="min-h-0 min-w-0 flex-1"
......@@ -957,7 +1057,7 @@ export default function DiffPanel({
9571057 (node): node is HTMLElement =>
9581058 node instanceof HTMLElement && node.hasAttribute("data-title"),
9591059 );
960 const filePath = title?.textContent?.trim();
1060 const filePath = title?.textContent;
9611061 // The filename remains the explicit "open in editor" affordance.
9621062 if (filePath) {
9631063 openDiffFile(filePath);
......@@ -967,9 +1067,7 @@ export default function DiffPanel({
9671067 (node): node is HTMLElement =>
9681068 node instanceof HTMLElement && node.hasAttribute("data-diffs-header"),
9691069 );
970 const headerFilePath = header
971 ?.querySelector("[data-title]")
972 ?.textContent?.trim();
1070 const headerFilePath = header?.querySelector("[data-title]")?.textContent;
9731071 if (!headerFilePath) return;
9741072 const file = codeViewFiles.find(
9751073 (candidate) => candidate.filePath === headerFilePath,
......@@ -980,16 +1078,43 @@ export default function DiffPanel({
9801078 <AnnotatableCodeView
9811079 key={collapseScopeKey ?? reviewSectionId}
9821080 viewerRef={setCodeView}
983 codeViewKey={codeViewMountKey}
1081 codeViewKey={`${codeViewMountKey}:${lazySource ? filePatchScope : "preview"}`}
9841082 className="h-full min-h-0 overflow-auto"
9851083 files={codeViewFiles}
1084 renderCodeViewFooter={renderLoadingBoundary}
9861085 sectionId={reviewSectionId}
9871086 sectionTitle={reviewSectionTitle}
9881087 composerDraftTarget={composerDraftTarget}
989 renderHeaderFilenameSuffix={(fileDiff) => (
990 <DiffFilePathCopyButton filePath={resolveFileDiffPath(fileDiff)} />
991 )}
992 renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
1088 renderHeaderFilenameSuffix={(fileDiff) => {
1089 const path = resolveFileDiffPath(fileDiff);
1090 const stat = fileStats.get(path);
1091 return (
1092 <>
1093 <DiffFilePathCopyButton filePath={path} />
1094 {stat ? (
1095 <DiffFileStatus {...fileStates.get(path)} retry={() => retry(path)} />
1096 ) : null}
1097 </>
1098 );
1099 }}
1100 {...(lazySource
1101 ? {
1102 unsafeCSSExtra:
1103 "[data-additions-count], [data-deletions-count] { display: none; }",
1104 renderHeaderMetadata: (fileDiff: FileDiffMetadata) => {
1105 const stat = fileStats.get(resolveFileDiffPath(fileDiff));
1106 return stat ? (
1107 <DiffStatLabel
1108 additions={stat.additions}
1109 deletions={stat.deletions}
1110 />
1111 ) : null;
1112 },
1113 }
1114 : {})}
1115 renderHeaderPrefix={(fileDiff, fileKey) => {
1116 const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;
1117 const collapsed = unavailable || collapsedDiffFileKeys.has(fileKey);
9931118 const filePath = resolveFileDiffPath(fileDiff);
9941119 return (
9951120 <Tooltip>
......@@ -1006,6 +1131,7 @@ export default function DiffPanel({
10061131 collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
10071132 }
10081133 aria-expanded={!collapsed}
1134 disabled={unavailable}
10091135 onClick={(event) => {
10101136 event.stopPropagation();
10111137 toggleDiffFileCollapsed(fileKey);
......@@ -1052,7 +1178,9 @@ export default function DiffPanel({
10521178 ) : (
10531179 <div className="min-h-0 flex-1 overflow-auto p-2">
10541180 <div className="space-y-2">
1055 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
1181 <p className="text-[11px] text-muted-foreground/75">
1182 {renderablePatch?.kind === "raw" ? renderablePatch.reason : null}
1183 </p>
10561184 <pre
10571185 className={cn(
10581186 "max-h-[72vh] rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90",
......@@ -1061,7 +1189,7 @@ export default function DiffPanel({
10611189 : "overflow-auto",
10621190 )}
10631191 >
1064 {renderablePatch.text}
1192 {renderablePatch?.kind === "raw" ? renderablePatch.text : null}
10651193 </pre>
10661194 </div>
10671195 </div>
apps/web/src/components/DiffPanelShell.tsx+1-1
......@@ -46,7 +46,7 @@ export function DiffPanelShell(props: {
4646 );
4747}
4848
49function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) {
49export function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) {
5050 return (
5151 <div className="flex h-8 items-center gap-2 px-2 pr-3">
5252 <div className="flex size-5 shrink-0 items-center justify-center">
apps/web/src/components/diffs/AnnotatableCodeView.tsx+14
......@@ -86,6 +86,9 @@ interface AnnotatableCodeViewProps {
8686 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;
8787 viewerRef?: Ref<AnnotatableCodeViewHandle>;
8888 className?: string;
89 renderCodeViewFooter?: () => ReactNode;
90 unsafeCSSExtra?: string;
91 renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode;
8992 renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode;
9093 renderHeaderPrefix: (
9194 fileDiff: FileDiffMetadata,
......@@ -107,6 +110,9 @@ export function AnnotatableCodeView({
107110 options,
108111 viewerRef,
109112 className,
113 renderCodeViewFooter,
114 unsafeCSSExtra,
115 renderHeaderMetadata,
110116 renderHeaderFilenameSuffix,
111117 renderHeaderPrefix,
112118}: AnnotatableCodeViewProps) {
......@@ -245,6 +251,14 @@ export function AnnotatableCodeView({
245251 key={codeViewKey}
246252 {...(viewerRef ? { viewerRef } : {})}
247253 {...(className ? { className } : {})}
254 {...(unsafeCSSExtra ? { unsafeCSSExtra } : {})}
255 {...(renderHeaderMetadata
256 ? {
257 renderHeaderMetadata: (item: CodeViewItem<DiffCommentAnnotationGroup>) =>
258 item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null,
259 }
260 : {})}
261 {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})}
248262 items={items}
249263 selectedLines={selectedLines}
250264 onSelectedLinesChange={setSelectedLines}
apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx created+27
......@@ -0,0 +1,27 @@
1import { useEffect, useRef } from "react";
2import { DiffFileHeaderSkeleton } from "../DiffPanelShell";
3
4/** Load the next batch before the reader reaches the end of the current files. */
5export function DiffFileLoadingBoundary({ load, count }: { load: () => void; count: number }) {
6 const ref = useRef<HTMLDivElement>(null);
7 useEffect(() => {
8 if (!ref.current) return;
9 const observer = new IntersectionObserver(
10 (entries) => {
11 if (entries.some((entry) => entry.isIntersecting)) load();
12 },
13 { rootMargin: "600px" },
14 );
15 observer.observe(ref.current);
16 return () => observer.disconnect();
17 }, [load]);
18 return (
19 <div ref={ref} role="status" aria-label="Loading diff…">
20 {Array.from({ length: Math.min(count, 4) }, (_, index) => (
21 <div key={index} aria-hidden className="border-b border-border/40">
22 <DiffFileHeaderSkeleton titleClassName="w-1/2 max-w-64" />
23 </div>
24 ))}
25 </div>
26 );
27}
apps/web/src/components/diffs/DiffFileStatus.tsx created+39
......@@ -0,0 +1,39 @@
1import { InfoIcon, RotateCwIcon } from "lucide-react";
2import { Button } from "../ui/button";
3import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4
5export function DiffFileStatus({
6 error,
7 truncated,
8 retry,
9}: {
10 error?: boolean | undefined;
11 truncated?: boolean | undefined;
12 retry: () => void;
13}) {
14 if (!error && !truncated) return null;
15 return (
16 <Tooltip>
17 <TooltipTrigger
18 render={
19 <Button
20 size="icon-micro"
21 variant="ghost-muted"
22 aria-label={error ? "Retry loading diff" : "Partial diff preview"}
23 onClick={(event) => {
24 event.stopPropagation();
25 if (error) retry();
26 }}
27 />
28 }
29 >
30 {error ? <RotateCwIcon className="size-3" /> : <InfoIcon className="size-3" />}
31 </TooltipTrigger>
32 <TooltipPopup>
33 {error
34 ? "Retry loading diff"
35 : "This file is too large to show in full. Counts include all changes."}
36 </TooltipPopup>
37 </Tooltip>
38 );
39}
apps/web/src/components/diffs/diffFileTree.logic.test.ts+3-3
......@@ -11,21 +11,21 @@ import {
1111} from "./diffFileTree.logic";
1212
1313function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata {
14 return { type, name: `b/${name}`, prevName: `a/${prevName}` } as FileDiffMetadata;
14 return { type, name, prevName } as FileDiffMetadata;
1515}
1616
1717describe("diffFileTreeEntries", () => {
1818 it("maps each change type to its git status under the file's current path", () => {
1919 expect(
2020 diffFileTreeEntries([
21 file("new", "src/a.ts"),
21 file("new", "a/src/a.ts"),
2222 file("deleted", "src/b.ts"),
2323 file("rename-pure", "src/c.ts", "src/old-c.ts"),
2424 file("rename-changed", "src/d.ts", "src/old-d.ts"),
2525 file("change", "README.md"),
2626 ]),
2727 ).toEqual([
28 { path: "src/a.ts", status: "added" },
28 { path: "a/src/a.ts", status: "added" },
2929 { path: "src/b.ts", status: "deleted" },
3030 { path: "src/c.ts", status: "renamed" },
3131 { path: "src/d.ts", status: "renamed" },
apps/web/src/components/diffs/useCodeViewFileReveal.ts+8-3
......@@ -8,7 +8,11 @@ interface FileRevealHandle {
88
99// Wait for a mounted viewer and expanded rows, then apply each tree click once.
1010// Keep scope stable until the diff or external file selection changes.
11export function useCodeViewFileReveal<TScope>(viewer: FileRevealHandle | null, scope: TScope) {
11export function useCodeViewFileReveal<TScope>(
12 viewer: FileRevealHandle | null,
13 scope: TScope,
14 readyFileKeys?: ReadonlyArray<string>,
15) {
1216 const [request, setRequest] = useState<{ fileKey: string; scope: TScope } | null>(null);
1317 const handledRequest = useRef<typeof request>(null);
1418
......@@ -18,11 +22,12 @@ export function useCodeViewFileReveal<TScope>(viewer: FileRevealHandle | null, s
1822 handledRequest.current = request;
1923 return;
2024 }
21 if (!viewer?.getInstance()) return;
25 if (!viewer?.getInstance() || (readyFileKeys && !readyFileKeys.includes(request.fileKey)))
26 return;
2227
2328 viewer.scrollTo({ type: "item", id: request.fileKey, align: "start" });
2429 handledRequest.current = request;
25 }, [request, scope, viewer]);
30 }, [request, scope, viewer, readyFileKeys]);
2631
2732 return useCallback((fileKey: string) => setRequest({ fileKey, scope }), [scope]);
2833}
apps/web/src/components/diffs/useReviewFilePatches.ts created+229
......@@ -0,0 +1,229 @@
1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import type { FileDiffMetadata } from "@pierre/diffs";
3import type { EnvironmentId, ReviewDiffPreviewSource } from "@t3tools/contracts";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Atom from "effect/unstable/reactivity/Atom";
6import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
7import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering";
8import { reviewEnvironment } from "~/state/review";
9
10export function useReviewFilePatches({
11 environmentId,
12 cwd,
13 source,
14 baseRef,
15 ignoreWhitespace,
16 theme,
17 revision,
18 preview,
19}: {
20 environmentId: EnvironmentId | undefined;
21 cwd: string | undefined;
22 source: ReviewDiffPreviewSource | null;
23 baseRef: string | null;
24 ignoreWhitespace: boolean;
25 theme: "light" | "dark";
26 revision: string | undefined;
27 preview: RenderablePatch | null;
28}) {
29 const registry = useContext(RegistryContext);
30 const scope = JSON.stringify([
31 environmentId,
32 cwd,
33 source?.kind,
34 source?.diffHash,
35 baseRef,
36 ignoreWhitespace,
37 ]);
38 const [requested, setRequested] = useState({ scope, indices: [0, 1, 2, 3] });
39 const indices = useMemo(
40 () => (requested.scope === scope ? requested.indices : [0, 1, 2, 3]),
41 [requested, scope],
42 );
43 const files = useMemo(
44 () =>
45 source?.files?.toSorted((a, b) =>
46 a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: "base" }),
47 ) ?? [],
48 [source?.files],
49 );
50 const queries = useMemo(
51 () =>
52 !environmentId || !cwd || !source
53 ? []
54 : indices
55 .filter((index) => index < files.length)
56 .map((index) => {
57 const file = files[index]!;
58 return {
59 index,
60 query: reviewEnvironment.diffFilePatch({
61 environmentId,
62 input: {
63 cacheKey: scope,
64 request: {
65 cwd,
66 ...(baseRef ? { baseRef } : {}),
67 ignoreWhitespace,
68 file: {
69 path: file.path,
70 previousPath: file.previousPath,
71 sourceKind: source.kind,
72 },
73 },
74 },
75 }),
76 };
77 }),
78 [environmentId, cwd, source, files, indices, scope, baseRef, ignoreWhitespace],
79 );
80 const previousPreview = useRef({ scope, revision, queries: [] as typeof queries });
81 useEffect(() => {
82 const previous = previousPreview.current;
83 previousPreview.current = { scope, revision, queries };
84 for (const { query } of queries) {
85 const changed = previous.scope === scope && previous.revision !== revision;
86 const cached =
87 !previous.queries.some((entry) => entry.query === query) &&
88 registry.get(query)._tag !== "Initial" &&
89 !registry.get(query).waiting;
90 if (changed || cached) registry.refresh(query);
91 }
92 }, [scope, revision, queries, registry]);
93 // Derived atoms parse each query result once, even when another file finishes loading.
94 const parsedQuery = useMemo(
95 () =>
96 Atom.family((query: ReturnType<typeof reviewEnvironment.diffFilePatch>) =>
97 Atom.map(query, (result) =>
98 AsyncResult.map(result, (source) => {
99 let patch = getRenderablePatch(source.diff, `diff-panel:${theme}`, {
100 compactPartialHunkOffsets: true,
101 });
102 if (patch?.kind === "files" && patch.files.length === 1 && source.files?.length === 1) {
103 const stat = source.files[0]!;
104 const file = { ...patch.files[0]!, name: stat.path };
105 if (stat.previousPath !== null) file.prevName = stat.previousPath;
106 else delete file.prevName;
107 patch = { ...patch, files: [file] };
108 }
109 return { source, patch };
110 }),
111 ),
112 ),
113 [theme],
114 );
115 const patches = useAtomValue(
116 useMemo(
117 () =>
118 Atom.make(
119 (get) => new Map(queries.map(({ index, query }) => [index, get(parsedQuery(query))])),
120 ),
121 [queries, parsedQuery],
122 ),
123 );
124 const pendingIndex = files.findIndex((_, index) => {
125 const patch = patches.get(index);
126 return !patch || patch._tag === "Initial";
127 });
128 const settledFileCount = source
129 ? pendingIndex < 0
130 ? files.length
131 : pendingIndex
132 : preview?.kind === "files"
133 ? preview.files.length
134 : 0;
135 const requestFiles = useCallback(
136 (indices: number[]) =>
137 setRequested((current) => {
138 const previous = current.scope === scope ? current.indices : [0, 1, 2, 3];
139 const added = indices.filter((index) => !previous.includes(index));
140 return added.length === 0 ? current : { scope, indices: [...previous, ...added] };
141 }),
142 [scope],
143 );
144 const loadNextFiles = useCallback(
145 () => requestFiles(Array.from({ length: 4 }, (_, index) => settledFileCount + index)),
146 [requestFiles, settledFileCount],
147 );
148 const requestFile = useCallback((index: number) => requestFiles([index]), [requestFiles]);
149 const retry = useCallback(
150 (path: string) => {
151 const query = queries.find(({ index }) => files[index]?.path === path)?.query;
152 if (query) registry.refresh(query);
153 },
154 [queries, files, registry],
155 );
156 const renderableFiles = useMemo(
157 () =>
158 source
159 ? files.map((file, index): FileDiffMetadata => {
160 const result = patches.get(index);
161 if (result?._tag === "Success" && result.value.patch?.kind === "files") {
162 const loaded = result.value.patch.files.find(
163 (candidate) => resolveFileDiffPath(candidate) === file.path,
164 );
165 if (loaded) return loaded;
166 }
167 return {
168 name: file.path,
169 ...(file.previousPath ? { prevName: file.previousPath } : {}),
170 type: file.previousPath ? "rename-changed" : "change",
171 hunks: [],
172 additionLines: [],
173 deletionLines: [],
174 splitLineCount: 0,
175 unifiedLineCount: 0,
176 isPartial: true,
177 cacheKey: `${scope}:${file.path}:pending`,
178 };
179 })
180 : (preview?.kind === "files" ? preview.files : []).toSorted((a, b) =>
181 resolveFileDiffPath(a).localeCompare(resolveFileDiffPath(b), undefined, {
182 numeric: true,
183 sensitivity: "base",
184 }),
185 ),
186 [source, files, patches, scope, preview],
187 );
188 const fileStates = new Map(
189 files.map((file, index) => {
190 const patch = patches.get(index);
191 return [
192 file.path,
193 {
194 error:
195 patch?._tag === "Failure" ||
196 (patch?._tag === "Success" &&
197 (patch.value.patch?.kind !== "files" ||
198 !patch.value.patch.files.some(
199 (candidate) => resolveFileDiffPath(candidate) === file.path,
200 ))),
201 truncated: patch?._tag === "Success" && patch.value.source.truncated,
202 },
203 ] as const;
204 }),
205 );
206 const readyFilePaths = useMemo(
207 () =>
208 new Set(
209 files
210 .filter((_, index) => {
211 const patch = patches.get(index);
212 return patch && patch._tag !== "Initial";
213 })
214 .map((file) => file.path),
215 ),
216 [files, patches],
217 );
218 return {
219 scope,
220 fileStates,
221 isPending: [...patches.values()].some((patch) => patch._tag === "Initial" || patch.waiting),
222 retry,
223 requestFile,
224 readyFilePaths,
225 renderableFiles,
226 settledFileCount,
227 loadNextFiles,
228 };
229}
apps/web/src/lib/diffFileContents.test.ts+2-2
......@@ -18,8 +18,8 @@ const SOURCE = {
1818function fileDiff(type: FileDiffMetadata["type"] = "rename-changed"): FileDiffMetadata {
1919 return {
2020 type,
21 prevName: "a/src/old-name.ts",
22 name: "b/src/new-name.ts",
21 prevName: "src/old-name.ts",
22 name: "src/new-name.ts",
2323 } as FileDiffMetadata;
2424}
2525
apps/web/src/lib/diffRendering.test.ts+30
......@@ -6,6 +6,8 @@ import {
66 buildPatchCacheKey,
77 getDiffLineStat,
88 getRenderablePatch,
9 resolveFileDiffPath,
10 resolveFileDiffPreviousPath,
911} from "./diffRendering";
1012
1113describe("buildPatchCacheKey", () => {
......@@ -32,6 +34,34 @@ describe("buildPatchCacheKey", () => {
3234});
3335
3436describe("getRenderablePatch", () => {
37 it.each([
38 ["a/example.ts", "a/example.ts"],
39 ["b/example.ts", "b/example.ts"],
40 ["a/before.ts", "b/after.ts"],
41 ])("preserves repository paths from %s to %s", (previousPath, path) => {
42 const parsed = getRenderablePatch(
43 [
44 `diff --git a/${previousPath} b/${path}`,
45 ...(previousPath === path
46 ? []
47 : ["similarity index 50%", `rename from ${previousPath}`, `rename to ${path}`]),
48 `--- a/${previousPath}`,
49 `+++ b/${path}`,
50 "@@ -1 +1 @@",
51 "-before",
52 "+after",
53 ].join("\n"),
54 );
55 expect(parsed?.kind).toBe("files");
56 if (parsed?.kind !== "files") return;
57 const file = parsed.files[0];
58 expect(file).toBeDefined();
59 if (!file) return;
60 expect(resolveFileDiffPath(file)).toBe(path);
61 expect(resolveFileDiffPreviousPath(file)).toBe(previousPath);
62 expect(buildFileDiffIdentityKey(file)).toBe(`${previousPath}\0${path}`);
63 });
64
3565 it("compacts partial hunk render offsets for virtualized review diffs", () => {
3666 const patch = [
3767 "diff --git a/example.ts b/example.ts",
apps/web/src/lib/diffRendering.ts+2-10
......@@ -144,11 +144,7 @@ export function getRenderablePatch(
144144}
145145
146146export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
147 const raw = fileDiff.name ?? fileDiff.prevName ?? "";
148 if (raw.startsWith("a/") || raw.startsWith("b/")) {
149 return raw.slice(2);
150 }
151 return raw;
147 return fileDiff.name ?? fileDiff.prevName ?? "";
152148}
153149
154150/**
......@@ -156,11 +152,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
156152 * path, and the hosts that resolve a diff position against both sides need both names.
157153 */
158154export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string {
159 const raw = fileDiff.prevName ?? fileDiff.name ?? "";
160 if (raw.startsWith("a/") || raw.startsWith("b/")) {
161 return raw.slice(2);
162 }
163 return raw;
155 return fileDiff.prevName ?? fileDiff.name ?? "";
164156}
165157
166158export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string {
packages/client-runtime/src/state/review.ts+35-1
......@@ -1,9 +1,17 @@
1import { WS_METHODS } from "@t3tools/contracts";
1import {
2 type ReviewDiffPreviewInput,
3 VcsUnsupportedOperationError,
4 WS_METHODS,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Semaphore from "effect/Semaphore";
8import { request } from "../rpc/client.ts";
29import { Atom } from "effect/unstable/reactivity";
310
411import {
512 createAtomCommandScheduler,
613 createEnvironmentRpcCommand,
14 createEnvironmentQueryAtomFamily,
715 createEnvironmentRpcQueryAtomFamily,
816} from "./runtime.ts";
917import type { EnvironmentRegistry } from "../connection/registry.ts";
......@@ -11,6 +19,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts";
1119export function createReviewEnvironmentAtoms<R, E>(
1220 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
1321) {
22 const patchReads = Semaphore.makeUnsafe(4);
1423 const diffFileScheduler = createAtomCommandScheduler();
1524 return {
1625 diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, {
......@@ -18,6 +27,31 @@ export function createReviewEnvironmentAtoms<R, E>(
1827 tag: WS_METHODS.reviewGetDiffPreview,
1928 staleTimeMs: 5_000,
2029 }),
30 diffFilePatch: createEnvironmentQueryAtomFamily(runtime, {
31 label: "environment-data:review:diff-file-patch",
32 staleTimeMs: 5 * 60_000,
33 execute: (input: {
34 request: ReviewDiffPreviewInput & { file: NonNullable<ReviewDiffPreviewInput["file"]> };
35 cacheKey: string;
36 }) =>
37 request(WS_METHODS.reviewGetDiffPreview, input.request).pipe(
38 patchReads.withPermit,
39 Effect.flatMap((result) => {
40 const source = result.sources.find(
41 (source) => source.kind === input.request.file.sourceKind,
42 );
43 return source
44 ? Effect.succeed(source)
45 : Effect.fail(
46 new VcsUnsupportedOperationError({
47 operation: "review.diffFilePatch",
48 kind: "git",
49 detail: "Diff no longer available. Refresh the comparison.",
50 }),
51 );
52 }),
53 ),
54 }),
2155 diffFileContents: createEnvironmentRpcCommand(runtime, {
2256 label: "environment-data:review:diff-file-contents",
2357 tag: WS_METHODS.reviewGetDiffFileContents,
packages/contracts/src/review.ts+17
......@@ -7,12 +7,27 @@ export const ReviewDiffPreviewInput = Schema.Struct({
77 cwd: TrimmedNonEmptyString,
88 baseRef: Schema.optional(TrimmedNonEmptyString),
99 ignoreWhitespace: Schema.optionalKey(Schema.Boolean),
10 file: Schema.optionalKey(
11 Schema.Struct({
12 path: Schema.NonEmptyString,
13 previousPath: Schema.NullOr(Schema.NonEmptyString),
14 sourceKind: Schema.Literals(["working-tree", "branch-range"]),
15 }),
16 ),
1017});
1118export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type;
1219
1320export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]);
1421export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type;
1522
23export const ReviewDiffFileStat = Schema.Struct({
24 path: Schema.String,
25 previousPath: Schema.NullOr(Schema.String),
26 additions: Schema.Number,
27 deletions: Schema.Number,
28});
29export type ReviewDiffFileStat = typeof ReviewDiffFileStat.Type;
30
1631export const ReviewDiffPreviewSource = Schema.Struct({
1732 id: TrimmedNonEmptyString,
1833 kind: ReviewDiffPreviewSourceKind,
......@@ -22,6 +37,8 @@ export const ReviewDiffPreviewSource = Schema.Struct({
2237 diff: Schema.String,
2338 diffHash: TrimmedNonEmptyString,
2439 truncated: Schema.Boolean,
40 /** Complete statistics, independent of patch limits. Absent on older servers. */
41 files: Schema.optionalKey(Schema.Array(ReviewDiffFileStat)),
2542});
2643export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type;
2744