Skip to content

Commit fd44a82

Browse files
committed
Render fullscreen reviews with Pierre file tree
1 parent 5136b30 commit fd44a82

5 files changed

Lines changed: 351 additions & 32 deletions

File tree

package-lock.json

Lines changed: 72 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@openai/codex-sdk": "^0.142.5",
4545
"@opencode-ai/sdk": "^1.17.13",
4646
"@pierre/diffs": "^1.2.5",
47+
"@pierre/trees": "1.0.0-beta.5",
4748
"better-sqlite3": "^12.10.0",
4849
"diff": "^8.0.3",
4950
"drizzle-orm": "^0.45.2",

src/ui/review-payload.tsx

Lines changed: 133 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { useMemo, useState } from "react";
1+
import { useEffect, useMemo, useState } from "react";
22
import { createRoot } from "react-dom/client";
3-
import { parsePatchFiles, type FileDiffMetadata, type FileDiffOptions } from "@pierre/diffs";
3+
import type { FileDiffOptions } from "@pierre/diffs";
44
import { FileDiff } from "@pierre/diffs/react";
5+
import type { GitStatusEntry } from "@pierre/trees";
6+
import { FileTree, useFileTree } from "@pierre/trees/react";
57
import type { HostContext, ToolResultCard } from "./card-types.js";
8+
import {
9+
buildReviewFileEntries,
10+
initialReviewPath,
11+
type ReviewFileEntry,
12+
} from "./review-model.js";
613

714
type ThemeType = "light" | "dark";
815

@@ -41,27 +48,30 @@ function ReviewPayload({
4148
hostContext,
4249
errorMessage = null,
4350
visibleFileCount,
51+
presentation = "inline",
4452
}: PayloadRendererOptions) {
45-
const patch = card.payload?.patch;
4653
const themeType: ThemeType = hostContext?.theme === "light" ? "light" : "dark";
47-
const files = useMemo(() => parseFiles(patch), [patch]);
48-
const visibleFiles = typeof visibleFileCount === "number"
49-
? files.slice(0, visibleFileCount)
50-
: files;
54+
const entries = useMemo(() => buildReviewFileEntries(card), [card]);
5155
const [openFiles, setOpenFiles] = useState(() => new Set<string>());
5256

5357
if (errorMessage) return <StatusLine message={errorMessage} tone="error" />;
54-
if (!patch) return <StatusLine message="Diff payload is not available." />;
55-
if (files.length === 0) return <StatusLine message="No diff hunks to review." />;
58+
if (!card.payload?.patch) return <StatusLine message="Diff payload is not available." />;
59+
if (entries.length === 0) return <StatusLine message="No diff hunks to review." />;
5660

5761
const options = diffOptions(themeType);
62+
if (presentation === "fullscreen") {
63+
return <FullscreenReview entries={entries} options={options} />;
64+
}
65+
66+
const visibleEntries = typeof visibleFileCount === "number"
67+
? entries.slice(0, visibleFileCount)
68+
: entries;
5869

5970
return (
6071
<div className="review-diff">
6172
<div className="review-diff-files">
62-
{visibleFiles.map((fileDiff, index) => {
63-
const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`;
64-
const stats = diffStats(fileDiff);
73+
{visibleEntries.map((entry) => {
74+
const key = entry.path;
6575
const isOpen = openFiles.has(key);
6676

6777
return (
@@ -80,15 +90,13 @@ function ReviewPayload({
8090
setOpenFiles(next);
8191
}}
8292
>
83-
<span className="review-diff-file-name">{fileDiff.name}</span>
93+
<span className="review-diff-file-name">{entry.path}</span>
8494
<span className="review-diff-file-stats">
85-
<span className="add">+{stats.additions}</span>
86-
<span className="remove">-{stats.removals}</span>
95+
<span className="add">+{entry.additions}</span>
96+
<span className="remove">-{entry.removals}</span>
8797
</span>
8898
</button>
89-
{isOpen ? (
90-
<FileDiff fileDiff={fileDiff} options={options} className="pierre-diff" />
91-
) : null}
99+
{isOpen ? <ReviewFileBody entry={entry} options={options} /> : null}
92100
</div>
93101
);
94102
})}
@@ -97,19 +105,116 @@ function ReviewPayload({
97105
);
98106
}
99107

100-
function parseFiles(patch: string | undefined): FileDiffMetadata[] {
101-
if (!patch) return [];
102-
return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files);
108+
function FullscreenReview({
109+
entries,
110+
options,
111+
}: {
112+
entries: ReviewFileEntry[];
113+
options: FileDiffOptions<undefined>;
114+
}) {
115+
const [selectedPath, setSelectedPath] = useState(() => initialReviewPath(entries));
116+
117+
useEffect(() => {
118+
setSelectedPath((currentPath) => initialReviewPath(entries, currentPath));
119+
}, [entries]);
120+
121+
const selectedEntry = entries.find((entry) => entry.path === selectedPath) ?? entries[0];
122+
123+
return (
124+
<div className="review-workspace">
125+
<section className="review-selected-file">
126+
<header className="review-selected-file-header">
127+
<div className="review-selected-file-title">
128+
<strong title={selectedEntry?.path}>{selectedEntry?.path}</strong>
129+
{selectedEntry?.previousPath && selectedEntry.previousPath !== selectedEntry.path ? (
130+
<span title={selectedEntry.previousPath}>from {selectedEntry.previousPath}</span>
131+
) : null}
132+
</div>
133+
{selectedEntry ? (
134+
<span className="review-diff-file-stats" aria-label="Selected file diff statistics">
135+
<span className="add">+{selectedEntry.additions}</span>
136+
<span className="remove">-{selectedEntry.removals}</span>
137+
</span>
138+
) : null}
139+
</header>
140+
<div className="review-selected-file-body">
141+
{selectedEntry ? (
142+
<ReviewFileBody entry={selectedEntry} options={options} />
143+
) : (
144+
<StatusLine message="Select a changed file to review it." />
145+
)}
146+
</div>
147+
</section>
148+
149+
<aside className="review-file-tree-panel" aria-label="Changed files">
150+
<div className="review-file-tree-header">
151+
<strong>Changed files</strong>
152+
<span>{entries.length}</span>
153+
</div>
154+
<div className="review-file-tree-body">
155+
<ReviewFileTree
156+
entries={entries}
157+
selectedPath={selectedEntry?.path}
158+
onSelect={setSelectedPath}
159+
/>
160+
</div>
161+
</aside>
162+
</div>
163+
);
103164
}
104165

105-
function diffStats(fileDiff: FileDiffMetadata): { additions: number; removals: number } {
106-
return fileDiff.hunks.reduce(
107-
(stats, hunk) => ({
108-
additions: stats.additions + hunk.additionLines,
109-
removals: stats.removals + hunk.deletionLines,
110-
}),
111-
{ additions: 0, removals: 0 },
166+
function ReviewFileTree({
167+
entries,
168+
selectedPath,
169+
onSelect,
170+
}: {
171+
entries: ReviewFileEntry[];
172+
selectedPath?: string;
173+
onSelect(path: string): void;
174+
}) {
175+
const paths = useMemo(() => entries.map((entry) => entry.path), [entries]);
176+
const gitStatus = useMemo<GitStatusEntry[]>(
177+
() => entries.map((entry) => ({ path: entry.path, status: entry.status })),
178+
[entries],
112179
);
180+
const { model } = useFileTree({
181+
paths,
182+
gitStatus,
183+
flattenEmptyDirectories: true,
184+
initialExpansion: "open",
185+
search: paths.length > 8,
186+
onSelectionChange(selectedPaths) {
187+
const path = selectedPaths.find((candidate) => paths.includes(candidate));
188+
if (path) onSelect(path);
189+
},
190+
});
191+
192+
useEffect(() => {
193+
if (!selectedPath) return;
194+
for (const path of model.getSelectedPaths()) {
195+
if (path !== selectedPath) model.getItem(path)?.deselect();
196+
}
197+
model.getItem(selectedPath)?.select();
198+
model.scrollToPath(selectedPath, { focus: false });
199+
}, [model, selectedPath]);
200+
201+
return <FileTree model={model} className="review-file-tree" />;
202+
}
203+
204+
function ReviewFileBody({
205+
entry,
206+
options,
207+
}: {
208+
entry: ReviewFileEntry;
209+
options: FileDiffOptions<undefined>;
210+
}) {
211+
if (!entry.fileDiff) {
212+
return (
213+
<StatusLine message="This file changed without a textual diff that can be rendered." />
214+
);
215+
}
216+
217+
return <FileDiff fileDiff={entry.fileDiff} options={options} className="pierre-diff" />;
113218
}
114219

115220
function diffOptions(themeType: ThemeType): FileDiffOptions<undefined> {

0 commit comments

Comments
 (0)