Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/code/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const changedFileSchema = z.object({
linesAdded: z.number().optional(),
linesRemoved: z.number().optional(),
staged: z.boolean().optional(),
patch: z.string().optional(),
});

export type ChangedFile = z.infer<typeof changedFileSchema>;
Expand Down
54 changes: 45 additions & 9 deletions apps/code/src/main/services/git/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ const log = logger.scope("git-service");
const FETCH_THROTTLE_MS = 5 * 60 * 1000;
const MAX_DIFF_LENGTH = 8000;

/**
* Wraps a GitHub API per-file patch (hunk content only) with
* the `diff --git` / `---` / `+++` header so that unified-diff
* parsers like `@pierre/diffs` can process it correctly.
*/
function toUnifiedDiffPatch(
rawPatch: string,
filename: string,
previousFilename: string | undefined,
status: ChangedFile["status"],
): string {
const oldPath = previousFilename ?? filename;
const fromPath = status === "added" ? "/dev/null" : `a/${oldPath}`;
const toPath = status === "deleted" ? "/dev/null" : `b/${filename}`;
return `diff --git a/${oldPath} b/${filename}\n--- ${fromPath}\n+++ ${toPath}\n${rawPatch}`;
}

@injectable()
export class GitService extends TypedEventEmitter<GitServiceEvents> {
private lastFetchTime = new Map<string, number>();
Expand Down Expand Up @@ -843,6 +860,7 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
previous_filename?: string;
additions: number;
deletions: number;
patch?: string;
}>
>;
const files = pages.flat();
Expand Down Expand Up @@ -870,6 +888,14 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
originalPath: f.previous_filename,
linesAdded: f.additions,
linesRemoved: f.deletions,
patch: f.patch
? toUnifiedDiffPatch(
f.patch,
f.filename,
f.previous_filename,
status,
)
: undefined,
};
});
} catch (error) {
Expand Down Expand Up @@ -903,8 +929,6 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
const result = await execGh([
"api",
`repos/${owner}/${repoName}/compare/${defaultBranch}...${branch}`,
"--jq",
".files",
]);

if (result.exitCode !== 0) {
Expand All @@ -913,13 +937,17 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
);
}

const files = JSON.parse(result.stdout) as Array<{
filename: string;
status: string;
previous_filename?: string;
additions: number;
deletions: number;
}> | null;
const response = JSON.parse(result.stdout) as {
files?: Array<{
filename: string;
status: string;
previous_filename?: string;
additions: number;
deletions: number;
patch?: string;
}>;
};
const files = response.files;

if (!files) return [];

Expand All @@ -946,6 +974,14 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
originalPath: f.previous_filename,
linesAdded: f.additions,
linesRemoved: f.deletions,
patch: f.patch
? toUnifiedDiffPatch(
f.patch,
f.filename,
f.previous_filename,
status,
)
: undefined,
};
});
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles";
import {
buildCloudEventSummary,
extractCloudFileDiff,
type ParsedToolCall,
} from "@features/task-detail/utils/cloudToolChanges";
import type { FileDiffMetadata } from "@pierre/diffs";
import { processFile } from "@pierre/diffs";
import { Flex, Spinner, Text } from "@radix-ui/themes";
import type { ChangedFile, Task } from "@shared/types";
import type { AcpMessage } from "@shared/types/session-events";
import { useMemo } from "react";
import { useReviewComment } from "../hooks/useReviewComment";
import type { DiffOptions, OnCommentCallback } from "../types";
Expand All @@ -18,30 +14,17 @@ import {
useReviewState,
} from "./ReviewShell";

const EMPTY_EVENTS: AcpMessage[] = [];

interface CloudReviewPageProps {
taskId: string;
task: Task;
}

export function CloudReviewPage({ taskId, task }: CloudReviewPageProps) {
const {
session,
effectiveBranch,
prUrl,
isRunActive,
changedFiles,
isLoading,
} = useCloudChangedFiles(taskId, task);
const { effectiveBranch, prUrl, isRunActive, remoteFiles, isLoading } =
useCloudChangedFiles(taskId, task);
const onComment = useReviewComment(taskId);
const events = session?.events ?? EMPTY_EVENTS;
const summary = useMemo(() => buildCloudEventSummary(events), [events]);

const allPaths = useMemo(
() => changedFiles.map((f) => f.path),
[changedFiles],
);
const allPaths = useMemo(() => remoteFiles.map((f) => f.path), [remoteFiles]);

const {
diffOptions,
Expand All @@ -54,9 +37,9 @@ export function CloudReviewPage({ taskId, task }: CloudReviewPageProps) {
uncollapseFile,
revealFile,
getDeferredReason,
} = useReviewState(changedFiles, allPaths);
} = useReviewState(remoteFiles, allPaths);

if (!prUrl && !effectiveBranch && changedFiles.length === 0) {
if (!prUrl && !effectiveBranch && remoteFiles.length === 0) {
if (isRunActive) {
return (
<Flex align="center" justify="center" height="100%">
Expand All @@ -81,17 +64,17 @@ export function CloudReviewPage({ taskId, task }: CloudReviewPageProps) {
return (
<ReviewShell
taskId={taskId}
fileCount={changedFiles.length}
fileCount={remoteFiles.length}
linesAdded={linesAdded}
linesRemoved={linesRemoved}
isLoading={isLoading && changedFiles.length === 0}
isEmpty={changedFiles.length === 0}
isLoading={isLoading && remoteFiles.length === 0}
isEmpty={remoteFiles.length === 0}
allExpanded={collapsedFiles.size === 0}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
onUncollapseFile={uncollapseFile}
>
{changedFiles.map((file) => {
{remoteFiles.map((file) => {
const isCollapsed = collapsedFiles.has(file.path);
const deferredReason = getDeferredReason(file.path);

Expand All @@ -115,7 +98,7 @@ export function CloudReviewPage({ taskId, task }: CloudReviewPageProps) {
<div key={file.path} data-file-path={file.path}>
<CloudFileDiff
file={file}
toolCalls={summary.toolCalls}
prUrl={prUrl}
options={diffOptions}
collapsed={isCollapsed}
onToggle={() => toggleFile(file.path)}
Expand All @@ -130,38 +113,46 @@ export function CloudReviewPage({ taskId, task }: CloudReviewPageProps) {

function CloudFileDiff({
file,
toolCalls,
prUrl,
options,
collapsed,
onToggle,
onComment,
}: {
file: ChangedFile;
toolCalls: Map<string, ParsedToolCall>;
prUrl: string | null;
options: DiffOptions;
collapsed: boolean;
onToggle: () => void;
onComment: OnCommentCallback;
}) {
const diff = useMemo(
() => extractCloudFileDiff(toolCalls, file.path),
[toolCalls, file.path],
);
const fileDiff = useMemo((): FileDiffMetadata | undefined => {
if (!file.patch) return undefined;
return processFile(file.patch, { isGitDiff: true });
}, [file.patch]);

const fileName = file.path.split("/").pop() || file.path;
const oldFile = useMemo(
() => ({ name: fileName, contents: diff?.oldText ?? "" }),
[fileName, diff],
);
const newFile = useMemo(
() => ({ name: fileName, contents: diff?.newText ?? "" }),
[fileName, diff],
);
if (!fileDiff) {
const hasChanges = (file.linesAdded ?? 0) + (file.linesRemoved ?? 0) > 0;
const reason = hasChanges ? "large" : "unavailable";
const githubFileUrl = prUrl
? `${prUrl}/files#diff-${file.path.replaceAll("/", "-")}`
: undefined;
return (
<DeferredDiffPlaceholder
filePath={file.path}
linesAdded={file.linesAdded ?? 0}
linesRemoved={file.linesRemoved ?? 0}
reason={reason}
collapsed={collapsed}
onToggle={onToggle}
externalUrl={githubFileUrl}
/>
);
}

return (
<InteractiveFileDiff
oldFile={oldFile}
newFile={newFile}
fileDiff={fileDiff}
options={{ ...options, collapsed }}
onComment={onComment}
renderCustomHeader={(fd) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ function PatchDiffView({
filePathRef.current = currentFilePath;

const hunkAnnotations = useMemo(
() => buildHunkAnnotations(fileDiff),
[fileDiff],
() => (repoPath ? buildHunkAnnotations(fileDiff) : []),
[fileDiff, repoPath],
);
const annotations = useMemo(
() =>
Expand Down Expand Up @@ -97,7 +97,7 @@ function PatchDiffView({
const handleRevert = useCallback(
async (hunkIndex: number) => {
const filePath = filePathRef.current;
if (!filePath) return;
if (!filePath || !repoPath) return;

setRevertingHunks((prev) => new Set(prev).add(hunkIndex));
setFileDiff((prev) => diffAcceptRejectHunk(prev, hunkIndex, "reject"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const AUTO_COLLAPSE_PATTERNS = [
/\.pbxproj$/,
];

export type DeferredReason = "deleted" | "large" | "generated";
export type DeferredReason = "deleted" | "large" | "generated" | "unavailable";

export function computeAutoDeferred(
files: {
Expand Down Expand Up @@ -495,6 +495,8 @@ function getDeferredMessage(
return `Generated file not rendered — ${totalLines} lines changed.`;
case "large":
return `Large diff not rendered — ${totalLines} lines changed.`;
case "unavailable":
return "Unable to load diff.";
}
}

Expand All @@ -506,14 +508,16 @@ export function DeferredDiffPlaceholder({
collapsed,
onToggle,
onShow,
externalUrl,
}: {
filePath: string;
linesAdded: number;
linesRemoved: number;
reason: DeferredReason;
collapsed: boolean;
onToggle: () => void;
onShow: () => void;
onShow?: () => void;
externalUrl?: string;
}) {
const { dirPath, fileName } = splitFilePath(filePath);

Expand All @@ -528,28 +532,55 @@ export function DeferredDiffPlaceholder({
onToggle={onToggle}
/>
{!collapsed && (
<button
type="button"
onClick={onShow}
<div
style={{
padding: "16px",
textAlign: "center",
color: "var(--gray-9)",
fontSize: "12px",
cursor: "pointer",
background: "var(--gray-2)",
borderBottom: "1px solid var(--gray-5)",
width: "100%",
border: "none",
}}
>
{getDeferredMessage(reason, linesAdded + linesRemoved)}{" "}
<span
style={{ color: "var(--accent-9)", textDecoration: "underline" }}
>
Load diff
</span>
</button>
{getDeferredMessage(reason, linesAdded + linesRemoved)}
{onShow ? (
<>
{" "}
<button
type="button"
onClick={onShow}
style={{
color: "var(--accent-9)",
textDecoration: "underline",
background: "none",
border: "none",
cursor: "pointer",
fontSize: "inherit",
padding: 0,
}}
>
Load diff
</button>
</>
) : externalUrl ? (
<>
{" "}
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
style={{
color: "var(--accent-9)",
textDecoration: "underline",
fontSize: "inherit",
}}
>
View on GitHub
</a>
</>
) : null}
</div>
)}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/code/src/renderer/features/code-review/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type OnCommentCallback = (
) => void;

export type PatchDiffProps = FileDiffProps<AnnotationMetadata> & {
repoPath: string;
repoPath?: string;
onComment?: OnCommentCallback;
};

Expand Down
Loading
Loading