diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index b4f57653fb6f..3bddf48e8a9d 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -382,24 +382,39 @@ export function ReviewSheet(props: ReviewSheetProps) { useEffect(() => { showAuxiliaryPane("inspector"); }, [environmentId, showAuxiliaryPane, threadId]); - const { error, reviewSections, selectedSection, refreshSelectedSection, selectSection } = - useReviewSections({ - enabled: isEnvironmentReady, - environmentId, - threadId, - reviewCache, - }); + const { + error, + reviewSections, + selectedSection, + refreshSelectedSection, + selectSection, + isSelectedSectionPending, + } = useReviewSections({ + enabled: isEnvironmentReady, + environmentId, + threadId, + reviewCache, + }); useReviewDiffPrewarming({ threadKey: reviewCache.threadKey, sections: reviewSections, selectedSectionId: selectedSection?.id ?? null, }); - const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = - useReviewDiffData({ - threadKey: reviewCache.threadKey, - selectedSection, - draftMessage, - }); + const { + headerDiffSummary, + nativeReviewDiffData, + parsedDiff, + pendingReviewCommentCount, + loadVisibleFile, + refreshFilePatches, + isPending: areFilePatchesPending, + } = useReviewDiffData({ + threadKey: reviewCache.threadKey, + environmentId, + cwd: selectedThreadCwd, + selectedSection, + draftMessage, + }); // Resolution returns null while Expo registers the native view (or forever // when the binary lacks it). Rendering a null component type crashes the // app, so callers must fall back — ThreadFeed's ReviewCommentCard does the @@ -412,11 +427,12 @@ export function ReviewSheet(props: ReviewSheetProps) { const handlePullToRefresh = useCallback(async () => { setIsPullRefreshing(true); try { + refreshFilePatches(); await refreshSelectedSection(); } finally { setIsPullRefreshing(false); } - }, [refreshSelectedSection]); + }, [refreshSelectedSection, refreshFilePatches]); const reviewFileNavigatorRef = useRef(null); const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; const fileVisibility = useReviewFileVisibility({ @@ -469,6 +485,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const handleSelectFile = useCallback( (fileId: string | null) => { + loadVisibleFile(fileId, true); commentSelection.clearSelection(); if (fileId !== null && collapsedFileIds.includes(fileId)) { toggleExpandedFile(fileId); @@ -481,13 +498,14 @@ export function ReviewSheet(props: ReviewSheetProps) { console.error("[review] Failed to navigate to diff file", error); }); }, - [collapsedFileIds, commentSelection, toggleExpandedFile], + [collapsedFileIds, commentSelection, toggleExpandedFile, loadVisibleFile], ); const handleVisibleFileChange = useCallback( (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => { + loadVisibleFile(event.nativeEvent.fileId ?? null); reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null); }, - [], + [loadVisibleFile], ); const renderInspector = useCallback( () => ( @@ -508,10 +526,11 @@ export function ReviewSheet(props: ReviewSheetProps) { (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { const { fileId } = event.nativeEvent; if (fileId) { + loadVisibleFile(fileId, true); toggleExpandedFile(fileId); } }, - [toggleExpandedFile], + [toggleExpandedFile, loadVisibleFile], ); const handleNativeToggleViewedFile = useCallback( @@ -818,7 +837,7 @@ export function ReviewSheet(props: ReviewSheetProps) { void handlePullToRefresh()} style={StyleSheet.absoluteFill} appearanceScheme={selectedTheme} @@ -862,7 +881,7 @@ export function ReviewSheet(props: ReviewSheetProps) { // iOS has no other refresh affordance here (the explicit // "Refresh current diff" menu is Android-only). void handlePullToRefresh()} /> } diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 39b9c0cef26e..25614c1d9137 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -244,6 +244,7 @@ function createNoticeRow(fileId: string, suffix: string, text: string): NativeRe } function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray { + if (file.notice) return [createNoticeRow(file.id, "loading", file.notice)]; if (file.rows.length > 0) { return []; } diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index ee568085f7a2..3a0a2fb1ac37 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { + applyReviewDiffMetadata, buildReviewParsedDiff, buildReviewSectionItems, getDefaultReviewSectionId, @@ -271,3 +272,34 @@ describe("buildReviewParsedDiff", () => { }); }); }); + +describe("applyReviewDiffMetadata", () => { + it("uses complete counts even when the preview contains only part of one file", () => { + const parsed = buildReviewParsedDiff( + [ + "diff --git a/large.txt b/large.txt", + "--- a/large.txt", + "+++ b/large.txt", + "@@ -1 +1 @@", + "-before", + "+after", + ].join("\n"), + "partial", + ); + const result = applyReviewDiffMetadata(parsed, { + truncated: true, + files: [ + { path: "large.txt", previousPath: null, additions: 4000, deletions: 3000 }, + { path: "unseen.txt", previousPath: null, additions: 100, deletions: 20 }, + ], + }); + expect(result.kind).toBe("files"); + if (result.kind !== "files") return; + expect(result.fileCount).toBe(2); + expect(result.additions).toBe(4100); + expect(result.deletions).toBe(3020); + expect(result.files[0]?.additions).toBe(4000); + expect(result.notice).toContain("Counts include all changes"); + expect(applyReviewDiffMetadata(parsed, null)).toEqual(parsed); + }); +}); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 202157b837cc..704e15c0088d 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -18,6 +18,9 @@ export interface ReviewSectionItem { readonly subtitle: string | null; readonly diff: string | null; readonly isLoading: boolean; + readonly files?: ReviewDiffPreviewSource["files"]; + readonly truncated?: boolean; + readonly source?: ReviewDiffPreviewSource; } export interface ReviewRenderableHunkRow { @@ -47,6 +50,7 @@ export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLine export interface ReviewRenderableFile { readonly id: string; readonly cacheKey: string; + readonly notice?: string; readonly path: string; readonly previousPath: string | null; readonly changeType: ChangeTypes; @@ -442,6 +446,9 @@ export function buildReviewSectionItems(input: { title: section.title, subtitle: gitSubtitle(section), diff: section.diff, + source: section, + ...(section.files ? { files: section.files } : {}), + truncated: section.truncated, isLoading: false, })); const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID); @@ -527,3 +534,27 @@ export function buildReviewParsedDiff( }; } } + +export function applyReviewDiffMetadata( + previewDiff: ReviewParsedDiff, + selectedSection: Pick | null, +): ReviewParsedDiff { + if (previewDiff.kind === "empty") return previewDiff; + const notice = selectedSection?.truncated + ? `This preview exceeds the size limit. Changes shown are incomplete.${selectedSection.files ? " Counts include all changes." : ""}` + : previewDiff.notice; + if (previewDiff.kind !== "files" || !selectedSection?.files) return { ...previewDiff, notice }; + const totals = selectedSection.files.reduce( + (total, file) => ({ + additions: total.additions + file.additions, + deletions: total.deletions + file.deletions, + }), + { additions: 0, deletions: 0 }, + ); + const stats = new Map(selectedSection.files.map((file) => [file.path, file])); + const files = previewDiff.files.map((file) => { + const stat = stats.get(file.path); + return stat ? { ...file, additions: stat.additions, deletions: stat.deletions } : file; + }); + return { ...previewDiff, ...totals, files, fileCount: selectedSection.files.length, notice }; +} diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 85aa6b032fae..03c81555aa33 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,10 +1,20 @@ -import { useEffect, useMemo } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { markReviewEvent, measureReviewWork } from "./reviewPerf"; import { getCachedReviewParsedDiff } from "./reviewState"; -import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; +import { + applyReviewDiffMetadata, + buildReviewParsedDiff, + type ReviewParsedDiff, + type ReviewSectionItem, +} from "./reviewModel"; + +import type { EnvironmentId } from "@t3tools/contracts"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { reviewEnvironment } from "../../state/review"; const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]); @@ -25,28 +35,33 @@ function logReviewDiffDiagnostic(message: string, details?: Record total + file.additions, 0)}`, + deletions: `-${files.reduce((total, file) => total + file.deletions, 0)}`, + }; } - - return { - additions: `+${parsedDiff.additions}`, - deletions: `-${parsedDiff.deletions}`, - }; + if (parsedDiff.kind !== "files") return { additions: null, deletions: null }; + return { additions: `+${parsedDiff.additions}`, deletions: `-${parsedDiff.deletions}` }; } export function useReviewDiffData(input: { readonly threadKey: string | null; + readonly environmentId: EnvironmentId | undefined; + readonly cwd: string | null; readonly selectedSection: ReviewSectionItem | null; readonly draftMessage: string; }) { const { draftMessage, selectedSection, threadKey } = input; const selectedSectionId = selectedSection?.id ?? null; - const parsedDiff = useMemo( + const previewDiff = useMemo( () => measureReviewWork("parse-diff", () => getCachedReviewParsedDiff({ @@ -57,7 +72,122 @@ export function useReviewDiffData(input: { ), [selectedSection?.diff, selectedSection?.id, threadKey], ); - const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]); + const registry = useContext(RegistryContext); + const source = selectedSection?.source; + const lazySource = source?.truncated && source.files ? source : null; + const { environmentId, cwd } = input; + const scope = JSON.stringify([environmentId, cwd, source?.kind, source?.diffHash]); + const [requested, setRequested] = useState({ scope, count: 3 }); + const count = requested.scope === scope ? requested.count : 3; + const queries = useMemo( + () => + !environmentId || !cwd || !lazySource + ? [] + : (lazySource.files ?? []).slice(0, count).map((file) => + reviewEnvironment.diffFilePatch({ + environmentId, + input: { + cacheKey: scope, + request: { + cwd, + ...(lazySource.kind === "branch-range" && lazySource.baseRef + ? { baseRef: lazySource.baseRef } + : {}), + file: { + path: file.path, + previousPath: file.previousPath, + sourceKind: lazySource.kind, + }, + }, + }, + }), + ), + [environmentId, cwd, lazySource, count, scope], + ); + const parsedQuery = useMemo( + () => + Atom.family((query: ReturnType) => + Atom.map(query, (result) => + AsyncResult.map(result, (source) => ({ + source, + parsed: buildReviewParsedDiff(source.diff, source.diffHash), + })), + ), + ), + [], + ); + const patches = useAtomValue( + useMemo( + () => Atom.make((get) => queries.map((query) => get(parsedQuery(query)))), + [queries, parsedQuery], + ), + ); + const refreshFilePatches = useCallback(() => { + for (const query of queries) registry.refresh(query); + }, [queries, registry]); + const loadVisibleFile = useCallback( + (fileId: string | null, retry = false) => { + const index = + fileId === null ? 0 : (lazySource?.files?.findIndex((file) => file.path === fileId) ?? -1); + if (index < 0) return; + setRequested((current) => + current.scope === scope && current.count >= index + 3 + ? current + : { scope, count: index + 3 }, + ); + if (retry && patches[index]?._tag === "Failure" && queries[index]) + registry.refresh(queries[index]); + }, + [lazySource, scope, patches, queries, registry], + ); + const parsedDiff = useMemo(() => { + if (!lazySource?.files) return applyReviewDiffMetadata(previewDiff, selectedSection); + const files = lazySource.files.map((stat, index) => { + const patch = patches[index]; + const parsed = patch?._tag === "Success" ? patch.value.parsed : null; + const loaded = + parsed?.kind === "files" ? parsed.files.find((file) => file.path === stat.path) : undefined; + return { + ...(loaded ?? { + path: stat.path, + previousPath: stat.previousPath, + changeType: "change" as const, + languageHint: null, + additionLines: [], + deletionLines: [], + rows: [], + cacheKey: `${lazySource.diffHash}:${stat.path}`, + }), + id: stat.path, + additions: stat.additions, + deletions: stat.deletions, + ...(patch?._tag === "Success" + ? patch.value.source.truncated + ? { notice: "File preview exceeds the size limit. Counts include all changes." } + : loaded + ? {} + : { notice: "Could not display file preview." } + : { + notice: + patch?._tag === "Failure" + ? "Could not load diff. Select the file to retry." + : "Loading diff…", + }), + }; + }); + return { + kind: "files", + files, + fileCount: files.length, + additions: files.reduce((total, file) => total + file.additions, 0), + deletions: files.reduce((total, file) => total + file.deletions, 0), + notice: null, + }; + }, [lazySource, previewDiff, selectedSection, patches]); + const headerDiffSummary = useMemo( + () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files), + [parsedDiff, selectedSection?.files], + ); const inlineReviewComments = useMemo( () => parseReviewInlineComments(draftMessage), [draftMessage], @@ -104,6 +234,9 @@ export function useReviewDiffData(input: { return { parsedDiff, + loadVisibleFile, + refreshFilePatches, + isPending: patches.some((patch) => patch._tag === "Initial" || patch.waiting), headerDiffSummary, nativeReviewDiffData, pendingReviewCommentCount, diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 87325490990c..d1a342f44e33 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -173,6 +173,8 @@ export function useReviewSections(input: { return { error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, + isSelectedSectionPending: + selectedSection?.kind === "turn" ? activeTurnDiff.isPending : diffPreview.isPending, loadingGitDiffs: diffPreview.isPending, loadingTurnIds, reviewSections, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fb741b28ee7c..13be548b988a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -13,12 +13,17 @@ import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; +import { + GitCommandError, + ReviewDiffPreviewInput, + type ReviewDiffFileContentsInput, +} from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -813,6 +818,119 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("review diff previews", () => { + it.effect("loads repository-relative files from a nested project directory", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "feature/nested"]); + yield* writeTextFile(cwd, "nested/tracked.txt", "committed\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "nested file"]); + yield* writeTextFile(cwd, "nested/tracked.txt", "changed\n"); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const nestedCwd = path.join(cwd, "nested"); + const preview = yield* driver.getReviewDiffPreview({ + cwd: nestedCwd, + baseRef: initialBranch, + }); + assert.equal( + preview.sources.find((source) => source.kind === "working-tree")!.files!.length, + 2, + ); + for (const source of preview.sources) { + for (const file of source.files ?? []) { + const scoped = yield* driver.getReviewDiffPreview({ + cwd: nestedCwd, + baseRef: initialBranch, + file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, + }); + const patch = scoped.sources.find((item) => item.kind === source.kind)!; + assert.deepStrictEqual(patch.files, [file]); + assert.include(patch.diff, `b/${file.path}`); + } + } + }), + ); + + it.effect("reads complete tracked and untracked manifests beyond 1 MB", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "untracked.txt", "untracked content\n"); + const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`); + const stats = paths.map((path) => `1\t0\t${path}\0`).join(""); + const untracked = [...paths, "untracked.txt"].join("\0") + "\0"; + assert.isAbove(stats.length, 1024 * 1024); + assert.isAbove(untracked.length, 1024 * 1024); + let readLargeUntracked = false; + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) { + if ( + command.args.includes("--numstat") && + command.args.includes(`${initialBranch}...HEAD`) + ) { + return Effect.succeed(makeSuccessfulHandle(stats)); + } + if (command.args.includes("ls-files") && command.args.includes("--others")) { + return Effect.succeed(makeSuccessfulHandle(readLargeUntracked ? untracked : "")); + } + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const branch = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + }); + const files = branch.sources.find((source) => source.kind === "branch-range")!.files!; + assert.equal(files.length, paths.length); + assert.equal(files.at(-1)?.path, paths.at(-1)); + assert.equal( + files.reduce((total, file) => total + file.additions, 0), + paths.length, + ); + readLargeUntracked = true; + const dirty = yield* driver.getReviewDiffPreview({ + cwd, + file: { path: "untracked.txt", previousPath: null, sourceKind: "working-tree" }, + }); + const source = dirty.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(source.files, [ + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(source.diff, "+untracked content"); + }), + ); + + it.effect("propagates patch failures instead of reporting an empty complete diff", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "README.md", "changed\n"); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("--patch") + ? Effect.succeed(makeNonRepositoryHandle()) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const result = yield* driver.getReviewDiffPreview({ cwd }).pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.operation, "GitVcsDriver.getReviewDiffPreview.patch"); + } + }), + ); + it.effect("drops an unterminated path from truncated NUL-separated git output", () => Effect.sync(() => { const paths = splitNullSeparatedGitStdoutPaths({ @@ -863,6 +981,14 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ignored.sources.find((source) => source.kind === "working-tree")?.diff, "", ); + assert.deepStrictEqual( + ignored.sources.find((source) => source.kind === "working-tree")?.files, + [], + ); + assert.deepStrictEqual( + ignored.sources.find((source) => source.kind === "branch-range")?.files, + [], + ); assert.strictEqual( ignored.sources.find((source) => source.kind === "branch-range")?.diff, "", @@ -898,6 +1024,134 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps complete stats for files beyond the combined patch limit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const largeContents = "a long line of changed content for the diff preview\n".repeat(4000); + yield* git(cwd, ["checkout", "-b", "feature/large"]); + yield* writeTextFile(cwd, "a-large.txt", largeContents); + yield* writeTextFile(cwd, "z-last.txt", "last file\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "large change"]); + yield* writeTextFile(cwd, "a-large.txt", largeContents.replaceAll("changed", "updated")); + yield* writeTextFile(cwd, "z-last.txt", "last file updated\n"); + yield* writeTextFile(cwd, "untracked.txt", largeContents); + + const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + const branch = preview.sources.find((source) => source.kind === "branch-range")!; + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.isTrue(branch.truncated); + assert.isTrue(dirty.truncated); + assert.notInclude(branch.diff, "z-last.txt"); + for (const source of [branch, dirty]) { + for (const file of source.files ?? []) { + const individual = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + file: { path: file.path, previousPath: file.previousPath, sourceKind: source.kind }, + }); + const patch = individual.sources.find((candidate) => candidate.kind === source.kind)!; + assert.isFalse(patch.truncated); + assert.deepStrictEqual(patch.files, [file]); + assert.include(patch.diff, `b/${file.path}`); + assert.isEmpty( + individual.sources.find((candidate) => candidate.kind !== source.kind)!.diff, + ); + } + } + + assert.deepStrictEqual(branch.files, [ + { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 }, + { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.deepStrictEqual(dirty.files, [ + { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 4000 }, + { path: "z-last.txt", previousPath: null, additions: 1, deletions: 1 }, + { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 }, + ]); + }), + ); + + it.effect("preserves rename paths, unusual filenames, and binary statistics", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/paths"]); + yield* git(cwd, ["mv", "README.md", "renamed.md"]); + yield* writeTextFile(cwd, "[literal].txt", "literal\n"); + yield* writeTextFile(cwd, " leading.txt", "whitespace path\n"); + yield* writeTextFile(cwd, "l.txt", "other\n"); + yield* writeTextFile(cwd, "binary.dat", "binary\0data"); + if ((yield* HostProcessPlatform) !== "win32") { + yield* writeTextFile(cwd, "tab\tand\nnewline.txt", "unusual path\n"); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "rename and add files"]); + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + }); + const branch = preview.sources.find((source) => source.kind === "branch-range")!; + for (const path of ["renamed.md", "[literal].txt", " leading.txt"]) { + const stat = branch.files!.find((file) => file.path === path)!; + const request = yield* Schema.decodeEffect(ReviewDiffPreviewInput)({ + cwd, + baseRef: initialBranch, + file: { path, previousPath: stat.previousPath, sourceKind: "branch-range" }, + }); + const result = yield* driver.getReviewDiffPreview(request); + const scoped = result.sources.find((source) => source.kind === "branch-range")!; + assert.deepStrictEqual(scoped.files, [stat]); + assert.notInclude(scoped.diff, "b/l.txt"); + if (path === "renamed.md") assert.include(scoped.diff, "rename from README.md"); + } + assert.include(branch.diff, "rename from README.md"); + assert.include(branch.diff, "rename to renamed.md"); + assert.deepInclude(branch.files ?? [], { + path: "renamed.md", + previousPath: "README.md", + additions: 0, + deletions: 0, + }); + assert.deepInclude(branch.files ?? [], { + path: "binary.dat", + previousPath: null, + additions: 0, + deletions: 0, + }); + if ((yield* HostProcessPlatform) !== "win32") { + assert.deepInclude(branch.files ?? [], { + path: "tab\tand\nnewline.txt", + previousPath: null, + additions: 1, + deletions: 0, + }); + } + }), + ); + + it.effect("reports staged and untracked changes before the first commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* git(cwd, ["init"]); + yield* writeTextFile(cwd, "staged.txt", "staged\n"); + yield* git(cwd, ["add", "staged.txt"]); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(dirty.files, [ + { path: "staged.txt", previousPath: null, additions: 1, deletions: 0 }, + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(dirty.diff, "b/staged.txt"); + assert.include(dirty.diff, "b/untracked.txt"); + }), + ); + it.effect("loads full file contents for working-tree diff expansion", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3c7e018ddea7..1035c56902ca 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -23,10 +23,12 @@ import { GitCommandError, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, + type ReviewDiffFileStat, type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -57,7 +59,6 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would // otherwise leak into the patch and leave every parsed file unnamed. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; -const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -191,6 +192,26 @@ function parseNumstatEntries( return entries; } +// -z preserves tabs/newlines in paths and gives renames two separate path fields. +function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { + const fields = stdout.split("\0"); + const files: ReviewDiffFileStat[] = []; + for (let index = 0; index < fields.length; index++) { + const field = fields[index]!; + const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(field); + if (!match) continue; + const previousPath = match[3] === "" ? fields[++index]! : null; + const path = previousPath !== null ? fields[++index]! : match[3]!; + files.push({ + path, + previousPath, + additions: match[1] === "-" ? 0 : Number(match[1]), + deletions: match[2] === "-" ? 0 : Number(match[2]), + }); + } + return files; +} + function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); @@ -2217,25 +2238,51 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readUntrackedReviewDiffs = Effect.fn("readUntrackedReviewDiffs")(function* (cwd: string) { + const readUntrackedReviewDiffs = Effect.fn("readUntrackedReviewDiffs")(function* ( + cwd: string, + selectedPath?: string, + ) { const untrackedResult = yield* executeGit( "GitVcsDriver.readUntrackedReviewDiffs.list", cwd, ["ls-files", "--others", "--exclude-standard", "-z"], { - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, + // The manifest must remain complete; only patch bodies have preview limits. + maxOutputBytes: Infinity, }, ); - const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult); + const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult).filter( + (path) => selectedPath === undefined || path === selectedPath, + ); if (untrackedPaths.length === 0) { - return { diff: "", truncated: untrackedResult.stdoutTruncated }; + return { diff: "", truncated: false, files: [] }; } const diffs = yield* Effect.forEach( untrackedPaths, - (relativePath) => - executeGit( + Effect.fnUntraced(function* (relativePath) { + const stat = yield* executeGit( + "GitVcsDriver.readUntrackedReviewDiffs.stat", + cwd, + [ + "diff", + "--no-index", + "--numstat", + "-z", + "--no-ext-diff", + "--no-textconv", + "--", + "/dev/null", + relativePath, + ], + { allowNonZeroExit: true }, + ); + const files = parseReviewNumstat(stat.stdout).map((file) => ({ + ...file, + path: relativePath, + previousPath: null, + })); + const patch = yield* executeGit( "GitVcsDriver.readUntrackedReviewDiffs.diff", cwd, [ @@ -2253,14 +2300,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { allowNonZeroExit: true, - maxOutputBytes: REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES, + maxOutputBytes: selectedPath + ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES + : REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES, appendTruncationMarker: true, }, - ), + ); + return { ...patch, files }; + }), { concurrency: 4 }, ); return { + files: diffs.flatMap((result) => result.files), diff: Arr.filterMap(diffs, (result) => result.stdout.trim().length > 0 ? Result.succeed(result.stdout) : Result.failVoid, ).join("\n"), @@ -2271,8 +2323,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { - const details = yield* statusDetailsLocal(input.cwd); - if (!details.isRepo) { + const pathArgs = input.file + ? [input.file.path, ...(input.file.previousPath ? [input.file.previousPath] : [])].map( + (path) => `:(top,literal)${path}`, + ) + : []; + const patchLimit = input.file + ? REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES + : REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES; + const repository = yield* resolveRepositoryPathsUncached(input.cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (!repository?.worktreeRoot) { return { cwd: input.cwd, generatedAt: yield* DateTime.now, @@ -2280,98 +2345,120 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - const branch = details.branch; + const cwd = repository.worktreeRoot; + const branch = repository.currentBranch; const baseRef = input.baseRef ?? (branch - ? yield* resolveBaseBranchForNoUpstream(input.cwd, branch).pipe( - Effect.orElseSucceed(() => null), - ) + ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null)) : null); - const dirtyTrackedResult = yield* executeGit( - "GitVcsDriver.getReviewDiffPreview.dirtyTracked", - input.cwd, + const diffArgs = [ + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + ]; + const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* (ref: string) { + const args = [...diffArgs, "--numstat", "-z"]; + const result = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.stat", + cwd, + [...args, ref, "--", ...pathArgs], + { allowNonZeroExit: true, maxOutputBytes: Infinity }, + ); + if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) }; + if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) { + const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [ + "hash-object", + "-t", + "tree", + (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null", + ])).trim(); + const stdout = yield* runGitStdoutWithOptions( + "GitVcsDriver.getReviewDiffPreview.unbornStat", + cwd, + [...args, emptyTree, "--", ...pathArgs], + { maxOutputBytes: Infinity }, + ); + return { ref: emptyTree, files: parseReviewNumstat(stdout) }; + } + return yield* new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffPreview.stat", + cwd, + command: "git diff --numstat", + detail: "Could not read complete diff statistics.", + exitCode: result.exitCode, + }); + }); + const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( + ref: string | null, + ) { + if (ref === null) return { stdout: "", stdoutTruncated: false, files: [] }; + const stat = yield* readStats(ref); + const patch = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.patch", + cwd, + [...diffArgs, "--patch", stat.ref, "--", ...pathArgs], + { maxOutputBytes: patchLimit, appendTruncationMarker: true }, + ); + return { ...patch, files: stat.files }; + }); + const [dirtyTrackedResult, baseResult, dirtyUntracked] = yield* Effect.all( [ - "diff", - "--patch", - "--no-color", - "--no-ext-diff", - "--no-textconv", - "--minimal", - ...PATCH_RENDER_PREFIX_ARGS, - ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), - "HEAD", - "--", + readTrackedDiff(input.file?.sourceKind === "branch-range" ? null : "HEAD"), + readTrackedDiff( + baseRef && branch && input.file?.sourceKind !== "working-tree" + ? `${baseRef}...HEAD` + : null, + ), + input.file?.sourceKind === "branch-range" + ? Effect.succeed({ diff: "", truncated: false, files: [] }) + : readUntrackedReviewDiffs(cwd, input.file?.path), ], - { - maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ).pipe( - Effect.orElseSucceed(() => ({ - exitCode: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), - ); - const dirtyUntracked = yield* readUntrackedReviewDiffs(input.cwd).pipe( - Effect.orElseSucceed(() => ({ diff: "", truncated: false })), + { concurrency: 3 }, ); + const dirtyFiles = [...dirtyTrackedResult.files, ...dirtyUntracked.files]; + const baseFiles = baseResult.files; const dirtyDiff = [dirtyTrackedResult.stdout.trimEnd(), dirtyUntracked.diff.trimEnd()] .filter((diff) => diff.length > 0) .join("\n"); - - const baseResult = - baseRef && branch - ? yield* executeGit( - "GitVcsDriver.getReviewDiffPreview.base", - input.cwd, + const baseDiff = baseResult.stdout; + const hashDiff = (diff: string, files: ReadonlyArray) => + crypto + .digest( + "SHA-256", + new TextEncoder().encode( [ - "diff", - "--patch", - "--no-color", - "--no-ext-diff", - "--no-textconv", - "--minimal", - ...PATCH_RENDER_PREFIX_ARGS, - ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), - `${baseRef}...HEAD`, - ], - { - maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ).pipe( - Effect.orElseSucceed(() => ({ - exitCode: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), - ) - : null; - const baseDiff = baseResult?.stdout ?? ""; - const hashDiff = (diff: string) => - crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( - Effect.map(Encoding.encodeHex), - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffPreview.hash", - command: "crypto.digest SHA-256", - cwd: input.cwd, - detail: "Failed to hash review diff.", - cause, - }), - ), - ); + diff, + ...files.flatMap((file) => [ + file.path, + file.previousPath ?? "", + String(file.additions), + String(file.deletions), + ]), + ].join("\0"), + ), + ) + .pipe( + Effect.map(Encoding.encodeHex), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", + cwd, + detail: "Failed to hash review diff.", + cause, + }), + ), + ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ - hashDiff(dirtyDiff), - hashDiff(baseDiff), + hashDiff(dirtyDiff, dirtyFiles), + hashDiff(baseDiff, baseFiles), ]); const sources: ReviewDiffPreviewSource[] = [ @@ -2382,6 +2469,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* baseRef: "HEAD", headRef: null, diff: dirtyDiff, + files: dirtyFiles, diffHash: dirtyDiffHash, truncated: dirtyTrackedResult.stdoutTruncated || dirtyUntracked.truncated, }, @@ -2392,13 +2480,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* baseRef, headRef: branch ?? "HEAD", diff: baseDiff, + files: baseFiles, diffHash: baseDiffHash, - truncated: baseResult?.stdoutTruncated ?? false, + truncated: baseResult.stdoutTruncated, }, ]; return { - cwd: input.cwd, + cwd, generatedAt: yield* DateTime.now, sources, }; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d764b9c6a9e2..79bb952516f8 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,6 +1,6 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; -import type { FileDiffContentsLoader } from "@pierre/diffs"; +import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -86,6 +86,10 @@ import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; +import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; +import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; +import { DiffFileStatus } from "./diffs/DiffFileStatus"; + type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; const DIFF_FILE_TREE_STORAGE_KEY = "t3code.diffFileTreeOpen"; @@ -288,30 +292,22 @@ export default function DiffPanel({ const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd ? fallbackBranchDiffPreview : primaryBranchDiffPreview; - const refreshBranchDiffPreview = branchDiffPreview.refresh; const canRefreshGitDiff = isGitRepo && selectedTurnId === null && activeThread != null && activeCwd != null; const activeThreadRefreshKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}` : null; - useEffect(() => { - if (!canRefreshGitDiff) return; - const refreshOnFocus = () => refreshBranchDiffPreview(); - window.addEventListener("focus", refreshOnFocus); - return () => window.removeEventListener("focus", refreshOnFocus); - }, [canRefreshGitDiff, refreshBranchDiffPreview]); - - useWorkspaceMutationRefresh({ - enabled: canRefreshGitDiff, - mutationId: workspaceMutationId, - refresh: refreshBranchDiffPreview, - resourceKey: `diff:${activeThreadRefreshKey ?? ""}`, - }); - const selectedGitSource = branchDiffPreview.data?.sources.find( (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), ); + const [filePatchRevision, setFilePatchRevision] = useState(0); + const refreshPreviewQuery = branchDiffPreview.refresh; + const refreshDiffFromUserAction = useCallback(() => { + setFilePatchRevision((revision) => revision + 1); + refreshPreviewQuery(); + }, [refreshPreviewQuery]); + const currentLoadDiffFiles = useMemo(() => { const preview = branchDiffPreview.data; if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) { @@ -405,17 +401,55 @@ export default function DiffPanel({ }), [resolvedTheme, selectedPatch, selectedTurnId], ); - const renderableFiles = useMemo(() => { - if (!renderablePatch || renderablePatch.kind !== "files") { - return []; - } - return renderablePatch.files.toSorted((left, right) => - resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { - numeric: true, - sensitivity: "base", - }), - ); - }, [renderablePatch]); + const lazySource = + !selectedTurn && selectedGitSource?.truncated && selectedGitSource.files + ? selectedGitSource + : null; + const lazySourceHash = lazySource?.diffHash; + const fileStats = useMemo( + () => new Map(lazySource?.files?.map((file) => [file.path, file])), + [lazySource?.files], + ); + const { + scope: filePatchScope, + refresh: refreshFilePatches, + isPending: areFilePatchesPending, + fileStates, + retry, + requestThrough, + renderableFiles, + settledFileCount, + loadNextFiles, + } = useReviewFilePatches({ + environmentId: activeThread?.environmentId, + cwd: branchDiffPreview.data?.cwd, + source: lazySource, + baseRef: selectedBaseRef, + ignoreWhitespace: diffIgnoreWhitespace, + theme: resolvedTheme, + revision: filePatchRevision, + preview: renderablePatch, + }); + const refreshBranchDiffPreview = useCallback(() => { + refreshFilePatches(); + refreshPreviewQuery(); + }, [refreshFilePatches, refreshPreviewQuery]); + + useEffect(() => { + if (!canRefreshGitDiff) return; + const refreshOnFocus = () => refreshBranchDiffPreview(); + window.addEventListener("focus", refreshOnFocus); + return () => window.removeEventListener("focus", refreshOnFocus); + }, [canRefreshGitDiff, refreshBranchDiffPreview]); + + useWorkspaceMutationRefresh({ + enabled: canRefreshGitDiff, + mutationId: workspaceMutationId, + refresh: refreshBranchDiffPreview, + resourceKey: `diff:${activeThreadRefreshKey ?? ""}`, + }); + + const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending; const renderableFileEntries = useMemo( () => renderableFiles.map((fileDiff) => ({ @@ -425,22 +459,48 @@ export default function DiffPanel({ })), [renderableFiles], ); + const renderLoadingBoundary = useCallback( + () => + settledFileCount < renderableFiles.length ? ( + + ) : null, + [settledFileCount, renderableFiles.length, loadNextFiles], + ); const codeViewFiles = useMemo( () => - renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => { + renderableFileEntries.slice(0, settledFileCount).map(({ fileDiff, fileKey, fileVersion }) => { return { fileDiff, filePath: resolveFileDiffPath(fileDiff), fileKey, fileVersion, - collapsed: collapsedDiffFileKeys.has(fileKey), + // Header-only placeholders use the viewer's collapsed geometry until their patch arrives. + collapsed: + collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, }; }), - [collapsedDiffFileKeys, renderableFileEntries], + [collapsedDiffFileKeys, renderableFileEntries, settledFileCount], + ); + const diffFileKeys = useMemo( + () => renderableFileEntries.map((file) => file.fileKey), + [renderableFileEntries], ); - const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); - const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]); + const diffLineStat = useMemo(() => { + if (!selectedTurn && selectedGitSource?.files) { + return selectedGitSource.files.reduce( + (total, file) => ({ + additions: total.additions + file.additions, + deletions: total.deletions + file.deletions, + }), + { additions: 0, deletions: 0 }, + ); + } + return getDiffLineStat(renderableFiles); + }, [renderableFiles, selectedGitSource, selectedTurn]); const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]); const selectedDiffFileKey = selectedFilePath ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null) @@ -455,23 +515,51 @@ export default function DiffPanel({ () => ({ collapseScopeKey, diffSelection }), [collapseScopeKey, diffSelection], ); - const requestTreeReveal = useCodeViewFileReveal(codeView, treeRevealScope); + const requestTreeReveal = useCodeViewFileReveal( + codeView, + treeRevealScope, + codeViewFiles.map((file) => file.fileKey), + ); const revealDiffFile = useCallback( (filePath: string) => { - const file = codeViewFiles.find((candidate) => candidate.filePath === filePath); + const index = renderableFileEntries.findIndex( + (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, + ); + const file = renderableFileEntries[index]; if (!file) return; - if (file.collapsed) { - setCollapsedDiffFiles((current) => { - const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); - next.delete(file.fileKey); - return { scopeKey: collapseScopeKey, fileKeys: next }; - }); + setCollapsedDiffFiles((current) => { + const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + next.delete(file.fileKey); + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + if (lazySource && index >= settledFileCount) { + requestThrough(index + 1); } requestTreeReveal(file.fileKey); }, - [codeViewFiles, collapseScopeKey, requestTreeReveal], + [ + renderableFileEntries, + collapseScopeKey, + requestTreeReveal, + lazySource, + settledFileCount, + requestThrough, + ], ); + const externalRevealRef = useRef<{ cache: string; key: string } | null>(null); + useEffect(() => { + if (!lazySource || !selectedFilePath) return; + const key = `${selectedFilePath}:${selectedFileRevealRequestId}`; + if ( + externalRevealRef.current?.cache === filePatchScope && + externalRevealRef.current.key === key + ) + return; + externalRevealRef.current = { cache: filePatchScope, key }; + revealDiffFile(selectedFilePath); + }, [lazySource, selectedFilePath, selectedFileRevealRequestId, filePatchScope, revealDiffFile]); + const openDiffFile = useCallback( (filePath: string) => { openDiffFilePrimaryAction({ @@ -743,14 +831,14 @@ export default function DiffPanel({ )}
- {codeViewFiles.length > 0 && ( + {codeViewFiles.length > 0 || (!selectedTurn && selectedGitSource?.files?.length) ? ( - )} + ) : null} {canRefreshGitDiff && ( } > - + - {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"} + {isRefreshingDiff ? "Refreshing diff…" : "Refresh diff"} )} - {codeViewFiles.length > 0 && ( + {diffFileKeys.length > 0 && ( - {codeViewFiles.length > 0 && ( + {diffFileKeys.length > 0 && (
- {isSelectedPatchTruncated && ( + {isSelectedPatchTruncated && !lazySource && (

- This diff was truncated because it exceeded the preview limit. The changes shown are - incomplete. + This preview exceeds the size limit. Changes shown are incomplete. + {selectedGitSource?.files ? " Totals include all changes." : ""}

)} {selectedPatchError && !renderablePatch && ( @@ -908,7 +996,7 @@ export default function DiffPanel({

{selectedPatchError}

)} - {!renderablePatch ? ( + {!renderablePatch && !lazySource ? ( isLoadingSelectedPatch ? (
) - ) : renderablePatch.kind === "files" ? ( + ) : lazySource || renderablePatch?.kind === "files" ? (
node instanceof HTMLElement && node.hasAttribute("data-title"), ); - const filePath = title?.textContent?.trim(); + const filePath = title?.textContent; // The filename remains the explicit "open in editor" affordance. if (filePath) { openDiffFile(filePath); @@ -956,9 +1044,7 @@ export default function DiffPanel({ (node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), ); - const headerFilePath = header - ?.querySelector("[data-title]") - ?.textContent?.trim(); + const headerFilePath = header?.querySelector("[data-title]")?.textContent; if (!headerFilePath) return; const file = codeViewFiles.find( (candidate) => candidate.filePath === headerFilePath, @@ -969,16 +1055,43 @@ export default function DiffPanel({ ( - - )} - renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { + renderHeaderFilenameSuffix={(fileDiff) => { + const path = resolveFileDiffPath(fileDiff); + const stat = fileStats.get(path); + return ( + <> + + {stat ? ( + retry(path)} /> + ) : null} + + ); + }} + {...(lazySource + ? { + unsafeCSSExtra: + "[data-additions-count], [data-deletions-count] { display: none; }", + renderHeaderMetadata: (fileDiff: FileDiffMetadata) => { + const stat = fileStats.get(resolveFileDiffPath(fileDiff)); + return stat ? ( + + ) : null; + }, + } + : {})} + renderHeaderPrefix={(fileDiff, fileKey) => { + const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true; + const collapsed = unavailable || collapsedDiffFileKeys.has(fileKey); const filePath = resolveFileDiffPath(fileDiff); return ( @@ -988,13 +1101,14 @@ export default function DiffPanel({ size="icon-micro" variant="ghost" className={cn( - "-ms-0.5 [--control-icon-color:currentColor] bg-transparent hover:bg-foreground/10", + "-ms-0.5 [--control-icon-color:currentColor]", getDiffCollapseIconClassName(fileDiff), )} aria-label={ collapsed ? `Expand ${filePath}` : `Collapse ${filePath}` } aria-expanded={!collapsed} + disabled={unavailable} onClick={(event) => { event.stopPropagation(); toggleDiffFileCollapsed(fileKey); @@ -1041,7 +1155,9 @@ export default function DiffPanel({ ) : (
-

{renderablePatch.reason}

+

+ {renderablePatch?.kind === "raw" ? renderablePatch.reason : null} +

-                    {renderablePatch.text}
+                    {renderablePatch?.kind === "raw" ? renderablePatch.text : null}
                   
diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 66a49ebf8084..f5a21e2b1d07 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -46,7 +46,7 @@ export function DiffPanelShell(props: { ); } -function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) { +export function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) { return (
diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index b0cf48ede57e..5255de5587d9 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -13,6 +13,7 @@ import { import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; +import { DiffPanelLoadingState } from "./DiffPanelShell"; export class DiffWorkerError extends Schema.TaggedError()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), @@ -116,16 +117,7 @@ function DiffWorkerReady({ children }: { children?: ReactNode }) { }; }, [ready, workerPool]); - return ready ? ( - children - ) : ( -
- Loading code... -
- ); + return ready ? children : ; } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index b8ace2340557..253a0f3d215e 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -86,6 +86,9 @@ interface AnnotatableCodeViewProps { options: StyledDiffCodeViewOptions; viewerRef?: Ref; className?: string; + renderCodeViewFooter?: () => ReactNode; + unsafeCSSExtra?: string; + renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode; renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode; renderHeaderPrefix: ( fileDiff: FileDiffMetadata, @@ -107,6 +110,9 @@ export function AnnotatableCodeView({ options, viewerRef, className, + renderCodeViewFooter, + unsafeCSSExtra, + renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, }: AnnotatableCodeViewProps) { @@ -245,6 +251,14 @@ export function AnnotatableCodeView({ key={codeViewKey} {...(viewerRef ? { viewerRef } : {})} {...(className ? { className } : {})} + {...(unsafeCSSExtra ? { unsafeCSSExtra } : {})} + {...(renderHeaderMetadata + ? { + renderHeaderMetadata: (item: CodeViewItem) => + item.type === "diff" ? renderHeaderMetadata(item.fileDiff) : null, + } + : {})} + {...(renderCodeViewFooter ? { renderCodeViewFooter } : {})} items={items} selectedLines={selectedLines} onSelectedLinesChange={setSelectedLines} diff --git a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx new file mode 100644 index 000000000000..d915c11f0a69 --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; +import { DiffFileHeaderSkeleton } from "../DiffPanelShell"; + +/** Fetch ahead of the bottom edge without displaying empty file headers. */ +export function DiffFileLoadingBoundary({ load, count }: { load: () => void; count: number }) { + const ref = useRef(null); + useEffect(() => { + if (!ref.current) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) load(); + }, + { rootMargin: "600px" }, + ); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [load]); + return ( +
+ {Array.from({ length: count }, (_, index) => ( +
+ +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/diffs/DiffFileStatus.tsx b/apps/web/src/components/diffs/DiffFileStatus.tsx new file mode 100644 index 000000000000..a166fd91826d --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileStatus.tsx @@ -0,0 +1,49 @@ +import { InfoIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function DiffFileStatus({ + error, + truncated, + retry, +}: { + error?: boolean | undefined; + truncated?: boolean | undefined; + retry: () => void; +}) { + if (error) { + return ( + + ); + } + if (!truncated) return null; + return ( + + event.stopPropagation()} + /> + } + > + + + + This file is too large to show in full. Counts include all changes. + + + ); +} diff --git a/apps/web/src/components/diffs/useCodeViewFileReveal.ts b/apps/web/src/components/diffs/useCodeViewFileReveal.ts index 6107c524c7b1..2f3746f8454d 100644 --- a/apps/web/src/components/diffs/useCodeViewFileReveal.ts +++ b/apps/web/src/components/diffs/useCodeViewFileReveal.ts @@ -8,7 +8,11 @@ interface FileRevealHandle { // Wait for a mounted viewer and expanded rows, then apply each tree click once. // Keep scope stable until the diff or external file selection changes. -export function useCodeViewFileReveal(viewer: FileRevealHandle | null, scope: TScope) { +export function useCodeViewFileReveal( + viewer: FileRevealHandle | null, + scope: TScope, + readyFileKeys?: ReadonlyArray, +) { const [request, setRequest] = useState<{ fileKey: string; scope: TScope } | null>(null); const handledRequest = useRef(null); @@ -18,11 +22,12 @@ export function useCodeViewFileReveal(viewer: FileRevealHandle | null, s handledRequest.current = request; return; } - if (!viewer?.getInstance()) return; + if (!viewer?.getInstance() || (readyFileKeys && !readyFileKeys.includes(request.fileKey))) + return; viewer.scrollTo({ type: "item", id: request.fileKey, align: "start" }); handledRequest.current = request; - }, [request, scope, viewer]); + }, [request, scope, viewer, readyFileKeys]); return useCallback((fileKey: string) => setRequest({ fileKey, scope }), [scope]); } diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts new file mode 100644 index 000000000000..a597f09694ba --- /dev/null +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -0,0 +1,173 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { EnvironmentId, ReviewDiffPreviewSource } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext, useMemo, useState } from "react"; +import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering"; +import { reviewEnvironment } from "~/state/review"; + +export function useReviewFilePatches({ + environmentId, + cwd, + source, + baseRef, + ignoreWhitespace, + theme, + revision, + preview, +}: { + environmentId: EnvironmentId | undefined; + cwd: string | undefined; + source: ReviewDiffPreviewSource | null; + baseRef: string | null; + ignoreWhitespace: boolean; + theme: "light" | "dark"; + revision: number; + preview: RenderablePatch | null; +}) { + const registry = useContext(RegistryContext); + const scope = JSON.stringify([ + environmentId, + cwd, + source?.kind, + source?.diffHash, + baseRef, + ignoreWhitespace, + revision, + ]); + const [requested, setRequested] = useState({ scope, count: 4 }); + const count = requested.scope === scope ? requested.count : 4; + const files = useMemo( + () => + source?.files?.toSorted((a, b) => + a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: "base" }), + ) ?? [], + [source?.files], + ); + const queries = useMemo( + () => + !environmentId || !cwd || !source + ? [] + : files.slice(0, count).map((file) => + reviewEnvironment.diffFilePatch({ + environmentId, + input: { + cacheKey: scope, + request: { + cwd, + ...(baseRef ? { baseRef } : {}), + ignoreWhitespace, + file: { + path: file.path, + previousPath: file.previousPath, + sourceKind: source.kind, + }, + }, + }, + }), + ), + [environmentId, cwd, source, files, count, scope, baseRef, ignoreWhitespace], + ); + // Derived atoms parse each query result once, even when another file finishes loading. + const parsedQuery = useMemo( + () => + Atom.family((query: ReturnType) => + Atom.map(query, (result) => + AsyncResult.map(result, (source) => ({ + source, + patch: getRenderablePatch(source.diff, `diff-panel:${theme}`, { + compactPartialHunkOffsets: true, + }), + })), + ), + ), + [theme], + ); + const patches = useAtomValue( + useMemo( + () => Atom.make((get) => queries.map((query) => get(parsedQuery(query)))), + [queries, parsedQuery], + ), + ); + const pendingIndex = patches.findIndex((patch) => patch._tag === "Initial"); + const settledFileCount = source + ? pendingIndex < 0 + ? patches.length + : pendingIndex + : preview?.kind === "files" + ? preview.files.length + : 0; + const requestThrough = useCallback( + (count: number) => + setRequested((current) => + current.scope === scope && current.count >= count ? current : { scope, count }, + ), + [scope], + ); + const loadNextFiles = useCallback( + () => requestThrough(settledFileCount + 4), + [requestThrough, settledFileCount], + ); + const refresh = useCallback(() => { + for (const query of queries) registry.refresh(query); + }, [queries, registry]); + const retry = useCallback( + (path: string) => { + const query = queries[files.findIndex((file) => file.path === path)]; + if (query) registry.refresh(query); + }, + [queries, files, registry], + ); + const renderableFiles = useMemo( + () => + source + ? files.map((file, index): FileDiffMetadata => { + const result = patches[index]; + if (result?._tag === "Success" && result.value.patch?.kind === "files") { + const loaded = result.value.patch.files.find( + (candidate) => resolveFileDiffPath(candidate) === file.path, + ); + if (loaded) return loaded; + } + return { + name: file.path, + ...(file.previousPath ? { prevName: file.previousPath } : {}), + type: file.previousPath ? "rename-changed" : "change", + hunks: [], + additionLines: [], + deletionLines: [], + splitLineCount: 0, + unifiedLineCount: 0, + isPartial: true, + cacheKey: `${scope}:${file.path}:pending`, + }; + }) + : (preview?.kind === "files" ? preview.files : []).toSorted((a, b) => + resolveFileDiffPath(a).localeCompare(resolveFileDiffPath(b), undefined, { + numeric: true, + sensitivity: "base", + }), + ), + [source, files, patches, scope, preview], + ); + const fileStates = new Map( + files.map((file, index) => [ + file.path, + { + error: patches[index]?._tag === "Failure", + truncated: patches[index]?._tag === "Success" && patches[index].value.source.truncated, + }, + ]), + ); + return { + scope, + refresh, + fileStates, + isPending: patches.some((patch) => patch._tag === "Initial" || patch.waiting), + retry, + requestThrough, + renderableFiles, + settledFileCount, + loadNextFiles, + }; +} diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts index ddd13db834bf..24fe3fb6fa76 100644 --- a/packages/client-runtime/src/state/review.ts +++ b/packages/client-runtime/src/state/review.ts @@ -1,9 +1,17 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { + type ReviewDiffPreviewInput, + VcsUnsupportedOperationError, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; +import { request } from "../rpc/client.ts"; import { Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, + createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -11,6 +19,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; export function createReviewEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { + const patchReads = Semaphore.makeUnsafe(4); const diffFileScheduler = createAtomCommandScheduler(); return { diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, { @@ -18,6 +27,31 @@ export function createReviewEnvironmentAtoms( tag: WS_METHODS.reviewGetDiffPreview, staleTimeMs: 5_000, }), + diffFilePatch: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:review:diff-file-patch", + staleTimeMs: 5 * 60_000, + execute: (input: { + request: ReviewDiffPreviewInput & { file: NonNullable }; + cacheKey: string; + }) => + request(WS_METHODS.reviewGetDiffPreview, input.request).pipe( + patchReads.withPermit, + Effect.flatMap((result) => { + const source = result.sources.find( + (source) => source.kind === input.request.file.sourceKind, + ); + return source + ? Effect.succeed(source) + : Effect.fail( + new VcsUnsupportedOperationError({ + operation: "review.diffFilePatch", + kind: "git", + detail: "Diff no longer available. Refresh the comparison.", + }), + ); + }), + ), + }), diffFileContents: createEnvironmentRpcCommand(runtime, { label: "environment-data:review:diff-file-contents", tag: WS_METHODS.reviewGetDiffFileContents, diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index 93f100b5fbf6..eee17de4e6f7 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -7,12 +7,27 @@ export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, baseRef: Schema.optional(TrimmedNonEmptyString), ignoreWhitespace: Schema.optionalKey(Schema.Boolean), + file: Schema.optionalKey( + Schema.Struct({ + path: Schema.NonEmptyString, + previousPath: Schema.NullOr(Schema.NonEmptyString), + sourceKind: Schema.Literals(["working-tree", "branch-range"]), + }), + ), }); export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]); export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; +export const ReviewDiffFileStat = Schema.Struct({ + path: Schema.String, + previousPath: Schema.NullOr(Schema.String), + additions: Schema.Number, + deletions: Schema.Number, +}); +export type ReviewDiffFileStat = typeof ReviewDiffFileStat.Type; + export const ReviewDiffPreviewSource = Schema.Struct({ id: TrimmedNonEmptyString, kind: ReviewDiffPreviewSourceKind, @@ -22,6 +37,8 @@ export const ReviewDiffPreviewSource = Schema.Struct({ diff: Schema.String, diffHash: TrimmedNonEmptyString, truncated: Schema.Boolean, + /** Complete statistics, independent of patch limits. Absent on older servers. */ + files: Schema.optionalKey(Schema.Array(ReviewDiffFileStat)), }); export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type;