Skip to content

Commit 1d55c63

Browse files
committed
Add file-scoped full-context diff viewing
- Propagate relative file paths and context mode through diff queries - Add per-file patch/full toggle in the diff panel and persist review state - Extend orchestration contracts and tests for scoped full-context diffs
1 parent b17502f commit 1d55c63

10 files changed

Lines changed: 214 additions & 16 deletions

File tree

apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ const make = Effect.gen(function* () {
133133
fromCheckpointRef,
134134
toCheckpointRef,
135135
fallbackFromToHead: false,
136+
relativePath: input.relativePath,
137+
contextMode: input.contextMode,
136138
});
137139

138140
const turnDiff: OrchestrationGetTurnDiffResultType = {
@@ -158,6 +160,8 @@ const make = Effect.gen(function* () {
158160
threadId: input.threadId,
159161
fromTurnCount: 0,
160162
toTurnCount: input.toTurnCount,
163+
relativePath: input.relativePath,
164+
contextMode: input.contextMode,
161165
}).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result));
162166

163167
return {

apps/server/src/checkpointing/Layers/CheckpointStore.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,21 @@ const makeCheckpointStore = Effect.gen(function* () {
240240
});
241241
}
242242

243+
const args = [
244+
"diff",
245+
"--patch",
246+
"--minimal",
247+
"--no-color",
248+
...(input.contextMode === "full" ? ["--unified=999999"] : []),
249+
fromCommitOid,
250+
toCommitOid,
251+
...(input.relativePath ? ["--", input.relativePath] : []),
252+
];
253+
243254
const result = yield* git.execute({
244255
operation,
245256
cwd: input.cwd,
246-
args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
257+
args,
247258
});
248259

249260
return result.stdout;

apps/server/src/checkpointing/Services/CheckpointStore.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { ServiceMap } from "effect";
1414
import type { Effect } from "effect";
1515

1616
import type { CheckpointStoreError } from "../Errors.ts";
17-
import { CheckpointRef } from "@okcode/contracts";
17+
import { CheckpointRef, type OrchestrationDiffContextMode } from "@okcode/contracts";
1818

1919
export interface CaptureCheckpointInput {
2020
readonly cwd: string;
@@ -32,6 +32,8 @@ export interface DiffCheckpointsInput {
3232
readonly fromCheckpointRef: CheckpointRef;
3333
readonly toCheckpointRef: CheckpointRef;
3434
readonly fallbackFromToHead?: boolean;
35+
readonly relativePath?: string;
36+
readonly contextMode?: OrchestrationDiffContextMode;
3537
}
3638

3739
export interface DeleteCheckpointRefsInput {

apps/web/src/components/DiffPanel.tsx

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
1515
import {
1616
expandDiffFile,
1717
reconcileDiffFileReviewState,
18+
setDiffFileContextMode,
1819
toggleDiffFileAccepted,
1920
toggleDiffFileCollapsed,
2021
type DiffFileReviewStateByPath,
@@ -165,33 +166,83 @@ function summarizeFileDiffStats(fileDiff: FileDiffMetadata): {
165166
);
166167
}
167168

169+
function resolveRenderableFileDiff(
170+
renderablePatch: RenderablePatch | null,
171+
filePath: string,
172+
): FileDiffMetadata | null {
173+
if (!renderablePatch || renderablePatch.kind !== "files") {
174+
return null;
175+
}
176+
return (
177+
renderablePatch.files.find((candidate) => resolveFileDiffPath(candidate) === filePath) ?? null
178+
);
179+
}
180+
181+
interface FileScopedCheckpointDiffInput {
182+
threadId: ThreadId | null;
183+
fromTurnCount: number | null;
184+
toTurnCount: number | null;
185+
cacheScope?: string | null;
186+
enabled: boolean;
187+
}
188+
168189
function DiffFileSection(props: {
169190
fileDiff: FileDiffMetadata;
170191
filePath: string;
171192
fileKey: string;
193+
checkpointDiffInput: FileScopedCheckpointDiffInput;
172194
diffRenderMode: DiffRenderMode;
173195
diffWordWrap: boolean;
174196
resolvedTheme: "light" | "dark";
175197
collapsed: boolean;
176198
accepted: boolean;
199+
contextMode: "patch" | "full";
177200
onOpenInEditor: (filePath: string) => void;
178201
onToggleCollapsed: (filePath: string) => void;
179202
onToggleAccepted: (filePath: string) => void;
203+
onContextModeChange: (filePath: string, contextMode: "patch" | "full") => void;
180204
}) {
181205
const {
182206
accepted,
207+
checkpointDiffInput,
183208
collapsed,
209+
contextMode,
184210
diffRenderMode,
185211
diffWordWrap,
186212
fileDiff,
187213
fileKey,
188214
filePath,
215+
onContextModeChange,
189216
onOpenInEditor,
190217
onToggleAccepted,
191218
onToggleCollapsed,
192219
resolvedTheme,
193220
} = props;
194221
const stats = summarizeFileDiffStats(fileDiff);
222+
const fullContextDiffQuery = useQuery(
223+
checkpointDiffQueryOptions({
224+
...checkpointDiffInput,
225+
relativePath: filePath,
226+
contextMode: "full",
227+
enabled: checkpointDiffInput.enabled && !collapsed && contextMode === "full",
228+
}),
229+
);
230+
const fullContextPatch = useMemo(
231+
() =>
232+
getRenderablePatch(
233+
contextMode === "full" ? fullContextDiffQuery.data?.diff : undefined,
234+
`diff-panel:file:${resolvedTheme}:${filePath}:full`,
235+
),
236+
[contextMode, filePath, fullContextDiffQuery.data?.diff, resolvedTheme],
237+
);
238+
const resolvedFileDiff =
239+
contextMode === "full" ? (resolveRenderableFileDiff(fullContextPatch, filePath) ?? fileDiff) : fileDiff;
240+
const fullContextError =
241+
contextMode === "full" && fullContextDiffQuery.error
242+
? fullContextDiffQuery.error instanceof Error
243+
? fullContextDiffQuery.error.message
244+
: "Failed to load full-file context."
245+
: null;
195246

196247
return (
197248
<section
@@ -227,6 +278,25 @@ function DiffFileSection(props: {
227278
<DiffStatLabel additions={stats.additions} deletions={stats.deletions} />
228279
</span>
229280
)}
281+
<ToggleGroup
282+
className="shrink-0"
283+
variant="outline"
284+
size="xs"
285+
value={[contextMode]}
286+
onValueChange={(value) => {
287+
const next = value[0];
288+
if (next === "patch" || next === "full") {
289+
onContextModeChange(filePath, next);
290+
}
291+
}}
292+
>
293+
<Toggle aria-label={`Show patch diff for ${filePath}`} value="patch">
294+
Patch
295+
</Toggle>
296+
<Toggle aria-label={`Show full file context for ${filePath}`} value="full">
297+
Full
298+
</Toggle>
299+
</ToggleGroup>
230300
<Button
231301
size="xs"
232302
variant={accepted ? "secondary" : "outline"}
@@ -242,8 +312,16 @@ function DiffFileSection(props: {
242312
</div>
243313
{!collapsed && (
244314
<div key={fileKey}>
315+
{contextMode === "full" && fullContextDiffQuery.isLoading ? (
316+
<DiffPanelLoadingState label="Loading full file..." />
317+
) : null}
318+
{fullContextError ? (
319+
<div className="border-b border-border/60 bg-destructive/8 px-3 py-2 text-[11px] text-destructive/80">
320+
{fullContextError}
321+
</div>
322+
) : null}
245323
<FileDiff
246-
fileDiff={fileDiff}
324+
fileDiff={resolvedFileDiff}
247325
options={{
248326
diffStyle: diffRenderMode === "split" ? "split" : "unified",
249327
lineDiffType: "none",
@@ -506,6 +584,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
506584
},
507585
[updateActiveReviewState],
508586
);
587+
const onChangeFileContextMode = useCallback(
588+
(filePath: string, contextMode: "patch" | "full") => {
589+
updateActiveReviewState((current) => setDiffFileContextMode(current, filePath, contextMode));
590+
},
591+
[updateActiveReviewState],
592+
);
509593

510594
const latestSelectedTurnId = orderedTurnDiffSummaries[0]?.turnId ?? null;
511595

@@ -674,17 +758,27 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
674758
const fileReviewState = activeReviewState[filePath] ?? {
675759
accepted: false,
676760
collapsed: true,
761+
contextMode: "patch" as const,
677762
};
678763
return (
679764
<DiffFileSection
680765
key={themedFileKey}
681766
accepted={fileReviewState.accepted}
767+
checkpointDiffInput={{
768+
threadId: activeThreadId,
769+
fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
770+
toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
771+
cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
772+
enabled: isGitRepo,
773+
}}
682774
collapsed={fileReviewState.collapsed}
775+
contextMode={fileReviewState.contextMode}
683776
diffRenderMode={diffRenderMode}
684777
diffWordWrap={diffWordWrap}
685778
fileDiff={fileDiff}
686779
fileKey={themedFileKey}
687780
filePath={filePath}
781+
onContextModeChange={onChangeFileContextMode}
688782
onOpenInEditor={openDiffFileInCodeViewer}
689783
onToggleAccepted={onToggleFileAccepted}
690784
onToggleCollapsed={onToggleFileCollapsed}

apps/web/src/lib/diffFileReviewState.test.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import {
33
expandDiffFile,
44
reconcileDiffFileReviewState,
5+
setDiffFileContextMode,
56
toggleDiffFileAccepted,
67
toggleDiffFileCollapsed,
78
} from "./diffFileReviewState";
@@ -10,38 +11,38 @@ describe("reconcileDiffFileReviewState", () => {
1011
it("preserves existing state for known files and drops removed files", () => {
1112
expect(
1213
reconcileDiffFileReviewState(["src/a.ts"], {
13-
"src/a.ts": { accepted: true, collapsed: true },
14-
"src/b.ts": { accepted: false, collapsed: true },
14+
"src/a.ts": { accepted: true, collapsed: true, contextMode: "full" },
15+
"src/b.ts": { accepted: false, collapsed: true, contextMode: "patch" },
1516
}),
1617
).toEqual({
17-
"src/a.ts": { accepted: true, collapsed: true },
18+
"src/a.ts": { accepted: true, collapsed: true, contextMode: "full" },
1819
});
1920
});
2021

2122
it("initializes new files as unaccepted and collapsed", () => {
2223
expect(reconcileDiffFileReviewState(["src/a.ts"], undefined)).toEqual({
23-
"src/a.ts": { accepted: false, collapsed: true },
24+
"src/a.ts": { accepted: false, collapsed: true, contextMode: "patch" },
2425
});
2526
});
2627
});
2728

2829
describe("toggleDiffFileAccepted", () => {
2930
it("marks a file accepted and collapses it", () => {
3031
expect(toggleDiffFileAccepted({}, "src/a.ts")).toEqual({
31-
"src/a.ts": { accepted: true, collapsed: true },
32+
"src/a.ts": { accepted: true, collapsed: true, contextMode: "patch" },
3233
});
3334
});
3435

3536
it("clears acceptance and re-expands the file", () => {
3637
expect(
3738
toggleDiffFileAccepted(
3839
{
39-
"src/a.ts": { accepted: true, collapsed: true },
40+
"src/a.ts": { accepted: true, collapsed: true, contextMode: "full" },
4041
},
4142
"src/a.ts",
4243
),
4344
).toEqual({
44-
"src/a.ts": { accepted: false, collapsed: false },
45+
"src/a.ts": { accepted: false, collapsed: false, contextMode: "full" },
4546
});
4647
});
4748
});
@@ -51,12 +52,28 @@ describe("toggleDiffFileCollapsed", () => {
5152
expect(
5253
toggleDiffFileCollapsed(
5354
{
54-
"src/a.ts": { accepted: true, collapsed: true },
55+
"src/a.ts": { accepted: true, collapsed: true, contextMode: "full" },
5556
},
5657
"src/a.ts",
5758
),
5859
).toEqual({
59-
"src/a.ts": { accepted: true, collapsed: false },
60+
"src/a.ts": { accepted: true, collapsed: false, contextMode: "full" },
61+
});
62+
});
63+
});
64+
65+
describe("setDiffFileContextMode", () => {
66+
it("updates the file context mode without changing other state", () => {
67+
expect(
68+
setDiffFileContextMode(
69+
{
70+
"src/a.ts": { accepted: true, collapsed: false, contextMode: "patch" },
71+
},
72+
"src/a.ts",
73+
"full",
74+
),
75+
).toEqual({
76+
"src/a.ts": { accepted: true, collapsed: false, contextMode: "full" },
6077
});
6178
});
6279
});
@@ -66,18 +83,18 @@ describe("expandDiffFile", () => {
6683
expect(
6784
expandDiffFile(
6885
{
69-
"src/a.ts": { accepted: true, collapsed: true },
86+
"src/a.ts": { accepted: true, collapsed: true, contextMode: "patch" },
7087
},
7188
"src/a.ts",
7289
),
7390
).toEqual({
74-
"src/a.ts": { accepted: true, collapsed: false },
91+
"src/a.ts": { accepted: true, collapsed: false, contextMode: "patch" },
7592
});
7693
});
7794

7895
it("returns the same object when the file is already expanded", () => {
7996
const state = {
80-
"src/a.ts": { accepted: false, collapsed: false },
97+
"src/a.ts": { accepted: false, collapsed: false, contextMode: "patch" },
8198
};
8299
// File is already expanded, so the same object reference is returned.
83100
expect(expandDiffFile(state, "src/a.ts")).toBe(state);

apps/web/src/lib/diffFileReviewState.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
export interface DiffFileReviewState {
22
collapsed: boolean;
33
accepted: boolean;
4+
contextMode: "patch" | "full";
45
}
56

67
export type DiffFileReviewStateByPath = Record<string, DiffFileReviewState>;
78

89
const DEFAULT_DIFF_FILE_REVIEW_STATE: DiffFileReviewState = {
910
collapsed: true,
1011
accepted: false,
12+
contextMode: "patch",
1113
};
1214

1315
export function reconcileDiffFileReviewState(
@@ -32,6 +34,7 @@ export function toggleDiffFileAccepted(
3234
[path]: {
3335
accepted,
3436
collapsed: accepted,
37+
contextMode: previous.contextMode,
3538
},
3639
};
3740
}
@@ -50,6 +53,24 @@ export function toggleDiffFileCollapsed(
5053
};
5154
}
5255

56+
export function setDiffFileContextMode(
57+
current: DiffFileReviewStateByPath,
58+
path: string,
59+
contextMode: "patch" | "full",
60+
): DiffFileReviewStateByPath {
61+
const previous = current[path] ?? DEFAULT_DIFF_FILE_REVIEW_STATE;
62+
if (previous.contextMode === contextMode) {
63+
return current;
64+
}
65+
return {
66+
...current,
67+
[path]: {
68+
...previous,
69+
contextMode,
70+
},
71+
};
72+
}
73+
5374
export function expandDiffFile(
5475
current: DiffFileReviewStateByPath,
5576
path: string,

0 commit comments

Comments
 (0)