Skip to content

Commit 3cbfe4a

Browse files
authored
Add file-scoped full-context diff viewing (#134)
* 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 * Omit unset diff query fields - Skip undefined checkpoint diff inputs when building queries - Tighten diff panel formatting and test typing
1 parent 5fc32da commit 3cbfe4a

10 files changed

Lines changed: 219 additions & 17 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+
...(input.relativePath ? { relativePath: input.relativePath } : {}),
137+
...(input.contextMode ? { 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: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
1616
import {
1717
expandDiffFile,
1818
reconcileDiffFileReviewState,
19+
setDiffFileContextMode,
1920
toggleDiffFileAccepted,
2021
toggleDiffFileCollapsed,
2122
type DiffFileReviewStateByPath,
@@ -166,33 +167,85 @@ function summarizeFileDiffStats(fileDiff: FileDiffMetadata): {
166167
);
167168
}
168169

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

197250
return (
198251
<section
@@ -228,6 +281,25 @@ function DiffFileSection(props: {
228281
<DiffStatLabel additions={stats.additions} deletions={stats.deletions} />
229282
</span>
230283
)}
284+
<ToggleGroup
285+
className="shrink-0"
286+
variant="outline"
287+
size="xs"
288+
value={[contextMode]}
289+
onValueChange={(value) => {
290+
const next = value[0];
291+
if (next === "patch" || next === "full") {
292+
onContextModeChange(filePath, next);
293+
}
294+
}}
295+
>
296+
<Toggle aria-label={`Show patch diff for ${filePath}`} value="patch">
297+
Patch
298+
</Toggle>
299+
<Toggle aria-label={`Show full file context for ${filePath}`} value="full">
300+
Full
301+
</Toggle>
302+
</ToggleGroup>
231303
<Button
232304
size="xs"
233305
variant={accepted ? "secondary" : "outline"}
@@ -243,8 +315,16 @@ function DiffFileSection(props: {
243315
</div>
244316
{!collapsed && (
245317
<div key={fileKey}>
318+
{contextMode === "full" && fullContextDiffQuery.isLoading ? (
319+
<DiffPanelLoadingState label="Loading full file..." />
320+
) : null}
321+
{fullContextError ? (
322+
<div className="border-b border-border/60 bg-destructive/8 px-3 py-2 text-[11px] text-destructive/80">
323+
{fullContextError}
324+
</div>
325+
) : null}
246326
<FileDiff
247-
fileDiff={fileDiff}
327+
fileDiff={resolvedFileDiff}
248328
options={{
249329
diffStyle: diffRenderMode === "split" ? "split" : "unified",
250330
lineDiffType: "none",
@@ -507,6 +587,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
507587
},
508588
[updateActiveReviewState],
509589
);
590+
const onChangeFileContextMode = useCallback(
591+
(filePath: string, contextMode: "patch" | "full") => {
592+
updateActiveReviewState((current) => setDiffFileContextMode(current, filePath, contextMode));
593+
},
594+
[updateActiveReviewState],
595+
);
510596

511597
const latestSelectedTurnId = orderedTurnDiffSummaries[0]?.turnId ?? null;
512598

@@ -675,17 +761,29 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
675761
const fileReviewState = activeReviewState[filePath] ?? {
676762
accepted: false,
677763
collapsed: true,
764+
contextMode: "patch" as const,
678765
};
679766
return (
680767
<DiffFileSection
681768
key={themedFileKey}
682769
accepted={fileReviewState.accepted}
770+
checkpointDiffInput={{
771+
threadId: activeThreadId,
772+
fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
773+
toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
774+
cacheScope: selectedTurn
775+
? `turn:${selectedTurn.turnId}`
776+
: conversationCacheScope,
777+
enabled: isGitRepo,
778+
}}
683779
collapsed={fileReviewState.collapsed}
780+
contextMode={fileReviewState.contextMode}
684781
diffRenderMode={diffRenderMode}
685782
diffWordWrap={diffWordWrap}
686783
fileDiff={fileDiff}
687784
fileKey={themedFileKey}
688785
filePath={filePath}
786+
onContextModeChange={onChangeFileContextMode}
689787
onOpenInEditor={openDiffFileInCodeViewer}
690788
onToggleAccepted={onToggleFileAccepted}
691789
onToggleCollapsed={onToggleFileCollapsed}

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

Lines changed: 30 additions & 13 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,19 +83,19 @@ 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 },
81-
};
97+
"src/a.ts": { accepted: false, collapsed: false, contextMode: "patch" as const },
98+
} satisfies Record<string, { accepted: boolean; collapsed: boolean; contextMode: "patch" }>;
8299
// File is already expanded, so the same object reference is returned.
83100
expect(expandDiffFile(state, "src/a.ts")).toBe(state);
84101
});

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)