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
92 changes: 81 additions & 11 deletions apps/app/src/components/code/DiffHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { cleanup, render, screen } from "@testing-library/react";
import { createStore, Provider as JotaiProvider } from "jotai";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginDiffRendererProps } from "@get-bb/plugin-sdk";
import type {
ExperimentalDiffFullFileContents,
PluginDiffRendererProps,
} from "@get-bb/plugin-sdk";
import { defaultResolvedCodeTheme } from "@bb/domain";
import { applyResolvedCodeTheme } from "@/lib/code-theme";
import {
Expand Down Expand Up @@ -58,6 +61,29 @@ const PATCH = [
"",
].join("\n");

const FULL_FILE_CONTENTS = {
old: {
path: "src/app.ts",
content: [
"const a = 1;",
"const b = 2;",
"const c = 4;",
"const oldTail = true;",
"",
].join("\n"),
},
new: {
path: "src/app.ts",
content: [
"const a = 1;",
"const b = 3;",
"const c = 4;",
"const newTail = true;",
"",
].join("\n"),
},
} satisfies ExperimentalDiffFullFileContents;

function parseFixture() {
const file = parseGitDiffFiles(PATCH)[0];
if (file === undefined) throw new Error("fixture patch did not parse");
Expand All @@ -77,9 +103,7 @@ function registerDiffRenderer(
sidebarFooterActions: [],
fileOpeners: [],
messageDirectives: [],
diffRenderers: [
{ id: "diffs", title: "Demo diffs", component },
],
diffRenderers: [{ id: "diffs", title: "Demo diffs", component }],
});
}

Expand All @@ -106,7 +130,12 @@ describe("DiffHost", () => {
});

render(
<DiffHost file={parseFixture()} patchText={PATCH} view="split" />,
<DiffHost
file={parseFixture()}
patchText={PATCH}
fullFileContents={null}
view="split"
/>,
);

expect(await screen.findByTestId("plugin-diff")).toBeDefined();
Expand All @@ -128,6 +157,7 @@ describe("DiffHost", () => {
<DiffHost
file={parseFixture()}
patchText={PATCH}
fullFileContents={FULL_FILE_CONTENTS}
view="split"
overflow="wrap"
showLineNumbers={false}
Expand All @@ -142,6 +172,7 @@ describe("DiffHost", () => {
expect(props?.view).toBe("split");
expect(props?.overflow).toBe("wrap");
expect(props?.showLineNumbers).toBe(false);
expect(props?.experimental_fullFileContents).toBe(FULL_FILE_CONTENTS);
expect(Object.keys(props ?? {})).not.toContain("onSelectionAddToChat");
expect(Object.keys(props ?? {})).not.toContain("file");
});
Expand All @@ -152,7 +183,7 @@ describe("DiffHost", () => {
return <div data-testid="plugin-diff">plugin diff</div>;
});

render(<DiffHost file={parseFixture()} />);
render(<DiffHost file={parseFixture()} fullFileContents={null} />);

await screen.findByTestId("plugin-diff");
const patch = receivedProps.at(-1)?.patch ?? "";
Expand All @@ -173,7 +204,13 @@ describe("DiffHost", () => {
path.endsWith(".ts") ? <Original /> : <div>plugin diff</div>,
);

render(<DiffHost file={parseFixture()} patchText={PATCH} />);
render(
<DiffHost
file={parseFixture()}
patchText={PATCH}
fullFileContents={null}
/>,
);

expect(await screen.findByTestId("bb-diff")).toBeDefined();
expect(bbDiff.loaded).toBe(true);
Expand All @@ -191,7 +228,11 @@ describe("DiffHost", () => {

render(
<JotaiProvider store={store}>
<DiffHost file={parseFixture()} patchText={PATCH} />
<DiffHost
file={parseFixture()}
patchText={PATCH}
fullFileContents={null}
/>
</JotaiProvider>,
);

Expand Down Expand Up @@ -230,7 +271,11 @@ describe("DiffHost", () => {

render(
<JotaiProvider store={store}>
<DiffHost file={parseFixture()} patchText={PATCH} />
<DiffHost
file={parseFixture()}
patchText={PATCH}
fullFileContents={null}
/>
</JotaiProvider>,
);

Expand All @@ -245,13 +290,19 @@ describe("DiffHost", () => {
throw new Error("replacement exploded");
});

render(<DiffHost file={parseFixture()} patchText={PATCH} />);
render(
<DiffHost
file={parseFixture()}
patchText={PATCH}
fullFileContents={null}
/>,
);

expect(await screen.findByTestId("bb-diff")).toBeDefined();
});

it("uses BB's renderer with resolved presentation defaults when nothing is registered", async () => {
render(<DiffHost file={parseFixture()} />);
render(<DiffHost file={parseFixture()} fullFileContents={null} />);

await screen.findByTestId("bb-diff");
expect(bbDiff.lastProps?.view).toBe("unified");
Expand All @@ -271,6 +322,7 @@ describe("experimental_Diff", () => {

await screen.findByTestId("plugin-diff");
expect(receivedProps.at(-1)?.path).toBe("src/app.ts");
expect(receivedProps.at(-1)?.experimental_fullFileContents).toBeNull();
expect(bbDiff.loaded).toBe(false);
});

Expand All @@ -296,6 +348,24 @@ describe("experimental_Diff", () => {
expect(patch).not.toContain("\r");
});

it("enriches BB's renderer with complete file contents for context expansion", async () => {
render(
<PluginDiff
patch={PATCH}
path="src/app.ts"
experimental_fullFileContents={FULL_FILE_CONTENTS}
/>,
);

await screen.findByTestId("bb-diff");
const file = bbDiff.lastProps?.file as ReturnType<
typeof parseFixture
> | null;
expect(file?.isPartial).toBe(false);
expect(file?.additionLines).toContain("const newTail = true;\n");
expect(bbDiff.lastProps?.expansionLineCount).toBe(30);
});

it("degrades to plain text instead of an empty diff when the patch will not parse", () => {
render(<PluginDiff patch="not a patch at all" path="notes.txt" />);

Expand Down
30 changes: 18 additions & 12 deletions apps/app/src/components/code/DiffHost.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Suspense, lazy, useMemo, type ReactNode } from "react";
import type { ExperimentalDiffFullFileContents } from "@get-bb/plugin-sdk";
import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot";
import type { ParsedGitDiffFile } from "@/components/git-diff/git-diff-parsing";
import { buildFileDiffPatchText } from "@/components/git-diff/git-diff-patch-text";
Expand All @@ -14,11 +15,14 @@ const DIFF_RENDERER_SLOT_KIND = "diffRenderer";

const BbDiff = lazy(() => import("./BbDiff"));

/** Unchanged lines revealed by one built-in expand-context action. */
const DEFAULT_DIFF_EXPANSION_LINE_COUNT = 30;

interface DiffHostProps extends Partial<DiffPresentation> {
/**
* The parsed diff to render. Callers parse it anyway for their own header,
* and the diff panel additionally enriches it with full file contents so the
* renderer can expand context between hunks.
* and callers with full file contents additionally enrich it so the renderer
* can expand context between hunks.
*/
file: ParsedGitDiffFile;
/**
Expand All @@ -27,13 +31,9 @@ interface DiffHostProps extends Partial<DiffPresentation> {
* reconstructs an equivalent single-file patch from `file`.
*/
patchText?: string;
/** Resolved semantic context forwarded to renderer replacements. */
fullFileContents: ExperimentalDiffFullFileContents | null;
className?: string;
/**
* Forwarded to BB's renderer; see {@link BbDiffProps.expansionLineCount}.
* Never reaches a plugin replacement — context expansion is a BB renderer
* capability, not part of the semantic contract.
*/
expansionLineCount?: number;
/** Rendered while BB's renderer chunk loads. */
fallback?: ReactNode;
onSelectionAddToChat?: (text: string) => void;
Expand All @@ -45,6 +45,8 @@ interface DiffHostProps extends Partial<DiffPresentation> {
* the environment diff panel's file bodies — and every plugin that calls
* `experimental_Diff` renders through here, so one
* `experimental_diffRenderer` registration replaces them all at once.
* Resolved full-file text is semantic input: the built-in renderer uses the
* enriched parsed file, while a replacement receives the plain text sides.
*
* BB's own renderer sits behind `lazy()`. A plugin replacement that never
* delegates therefore never downloads it, and `experimental_Original` costs
Expand All @@ -53,11 +55,11 @@ interface DiffHostProps extends Partial<DiffPresentation> {
export function DiffHost({
file,
patchText,
fullFileContents,
view = DEFAULT_DIFF_VIEW,
overflow = DEFAULT_CODE_OVERFLOW,
showLineNumbers = true,
className,
expansionLineCount,
fallback = null,
onSelectionAddToChat,
}: DiffHostProps) {
Expand All @@ -66,8 +68,7 @@ export function DiffHost({
// Only reconstructed when a replacement will actually read it: the walk is
// proportional to the rendered hunks, and BB's own renderer never needs it.
const semanticPatch = useMemo(
() =>
isReplaced ? (patchText ?? buildFileDiffPatchText(file)) : "",
() => (isReplaced ? (patchText ?? buildFileDiffPatchText(file)) : ""),
[file, isReplaced, patchText],
);

Expand All @@ -79,7 +80,11 @@ export function DiffHost({
overflow={overflow}
showLineNumbers={showLineNumbers}
className={className}
expansionLineCount={expansionLineCount}
expansionLineCount={
fullFileContents !== null && file.isPartial === false
? DEFAULT_DIFF_EXPANSION_LINE_COUNT
: undefined
}
onSelectionAddToChat={onSelectionAddToChat}
/>
</Suspense>
Expand All @@ -99,6 +104,7 @@ export function DiffHost({
view={view}
overflow={overflow}
showLineNumbers={showLineNumbers}
experimental_fullFileContents={fullFileContents}
experimental_Original={BoundOriginal}
/>
</div>
Expand Down
6 changes: 3 additions & 3 deletions apps/app/src/components/code/code-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ export interface BbDiffProps extends DiffPresentation {
className?: string;
/**
* How many unchanged lines each expand-context click reveals. Set ONLY by a
* caller that can attach full file contents to `file`: pierre renders an
* empty diff when it is given an expansion budget for a hunk-only patch,
* which is what the timeline supplies.
* host that received complete file contents and a successfully enriched
* `file`: pierre renders an empty diff when it is given an expansion budget
* for a hunk-only patch, which is what the timeline supplies.
*/
expansionLineCount?: number;
onSelectionAddToChat?: (text: string) => void;
Expand Down
Loading