Skip to content

Commit f7748a0

Browse files
authored
feat: add "hide whitespace changes" option for diffs (#2389)
1 parent 0ce7e56 commit f7748a0

16 files changed

Lines changed: 308 additions & 6 deletions

apps/desktop/src/clientPersistence.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
5252
autoOpenPlanSidebar: false,
5353
confirmThreadArchive: true,
5454
confirmThreadDelete: false,
55+
diffIgnoreWhitespace: true,
5556
diffWordWrap: true,
5657
favorites: [],
5758
providerModelPreferences: {},

apps/server/integration/orchestrationEngine.integration.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
506506
fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1),
507507
toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
508508
fallbackFromToHead: false,
509+
ignoreWhitespace: false,
509510
});
510511
assert.equal(incrementalDiff.includes("README.md"), true);
511512

@@ -514,6 +515,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
514515
fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0),
515516
toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
516517
fallbackFromToHead: false,
518+
ignoreWhitespace: false,
517519
});
518520
assert.equal(fullDiff.includes("README.md"), true);
519521

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

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe("CheckpointDiffQueryLive", () => {
4848
readonly fromCheckpointRef: CheckpointRef;
4949
readonly toCheckpointRef: CheckpointRef;
5050
readonly cwd: string;
51+
readonly ignoreWhitespace: boolean;
5152
}> = [];
5253

5354
const threadCheckpointContext = makeThreadCheckpointContext({
@@ -68,9 +69,14 @@ describe("CheckpointDiffQueryLive", () => {
6869
return true;
6970
}),
7071
restoreCheckpoint: () => Effect.succeed(true),
71-
diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) =>
72+
diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) =>
7273
Effect.sync(() => {
73-
diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd });
74+
diffCheckpointsCalls.push({
75+
fromCheckpointRef,
76+
toCheckpointRef,
77+
cwd,
78+
ignoreWhitespace,
79+
});
7480
return "diff patch";
7581
}),
7682
deleteCheckpointRefs: () => Effect.void,
@@ -102,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => {
102108
threadId,
103109
fromTurnCount: 0,
104110
toTurnCount: 1,
111+
ignoreWhitespace: true,
105112
});
106113
}).pipe(Effect.provide(layer)),
107114
);
@@ -113,6 +120,7 @@ describe("CheckpointDiffQueryLive", () => {
113120
cwd: "/tmp/workspace",
114121
fromCheckpointRef: expectedFromRef,
115122
toCheckpointRef,
123+
ignoreWhitespace: true,
116124
},
117125
]);
118126
expect(result).toEqual({
@@ -123,6 +131,67 @@ describe("CheckpointDiffQueryLive", () => {
123131
});
124132
});
125133

134+
it("defaults to hide whitespace changes", async () => {
135+
const projectId = ProjectId.make("project-default-whitespace");
136+
const threadId = ThreadId.make("thread-default-whitespace");
137+
const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
138+
const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = [];
139+
140+
const threadCheckpointContext = makeThreadCheckpointContext({
141+
projectId,
142+
threadId,
143+
workspaceRoot: "/tmp/workspace",
144+
worktreePath: null,
145+
checkpointTurnCount: 1,
146+
checkpointRef: toCheckpointRef,
147+
});
148+
149+
const checkpointStore: CheckpointStoreShape = {
150+
isGitRepository: () => Effect.succeed(true),
151+
captureCheckpoint: () => Effect.void,
152+
hasCheckpointRef: () => Effect.succeed(true),
153+
restoreCheckpoint: () => Effect.succeed(true),
154+
diffCheckpoints: ({ ignoreWhitespace }) =>
155+
Effect.sync(() => {
156+
diffCheckpointsCalls.push({ ignoreWhitespace });
157+
return "diff patch";
158+
}),
159+
deleteCheckpointRefs: () => Effect.void,
160+
};
161+
162+
const layer = CheckpointDiffQueryLive.pipe(
163+
Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
164+
Layer.provideMerge(
165+
Layer.succeed(ProjectionSnapshotQuery, {
166+
getSnapshot: () =>
167+
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
168+
getShellSnapshot: () =>
169+
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
170+
getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
171+
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
172+
getProjectShellById: () => Effect.succeed(Option.none()),
173+
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
174+
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
175+
getThreadShellById: () => Effect.succeed(Option.none()),
176+
getThreadDetailById: () => Effect.succeed(Option.none()),
177+
}),
178+
),
179+
);
180+
181+
await Effect.runPromise(
182+
Effect.gen(function* () {
183+
const query = yield* CheckpointDiffQuery;
184+
return yield* query.getTurnDiff({
185+
threadId,
186+
fromTurnCount: 0,
187+
toTurnCount: 1,
188+
});
189+
}).pipe(Effect.provide(layer)),
190+
);
191+
192+
expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]);
193+
});
194+
126195
it("fails when the thread is missing from the snapshot", async () => {
127196
const threadId = ThreadId.make("thread-missing");
128197

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const make = Effect.gen(function* () {
2424
const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")(
2525
function* (input) {
2626
const operation = "CheckpointDiffQuery.getTurnDiff";
27+
const ignoreWhitespace = input.ignoreWhitespace ?? true;
2728

2829
if (input.fromTurnCount === input.toTurnCount) {
2930
const emptyDiff: OrchestrationGetTurnDiffResultType = {
@@ -131,6 +132,7 @@ const make = Effect.gen(function* () {
131132
fromCheckpointRef,
132133
toCheckpointRef,
133134
fallbackFromToHead: false,
135+
ignoreWhitespace,
134136
});
135137

136138
const turnDiff: OrchestrationGetTurnDiffResultType = {
@@ -157,6 +159,7 @@ const make = Effect.gen(function* () {
157159
threadId: input.threadId,
158160
fromTurnCount: 0,
159161
toTurnCount: input.toTurnCount,
162+
ignoreWhitespace: input.ignoreWhitespace ?? true,
160163
}).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result));
161164

162165
return {

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,88 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {
114114
cwd: tmp,
115115
fromCheckpointRef,
116116
toCheckpointRef,
117+
ignoreWhitespace: true,
117118
});
118119

119120
expect(diff).toContain("diff --git");
120121
expect(diff).not.toContain("[truncated]");
121122
expect(diff).toContain("+line 04999");
122123
}),
123124
);
125+
126+
it.effect("can hide indentation churn when changes wrap existing lines", () =>
127+
Effect.gen(function* () {
128+
const tmp = yield* makeTmpDir();
129+
yield* initRepoWithCommit(tmp);
130+
const checkpointStore = yield* CheckpointStore;
131+
const threadId = ThreadId.make("thread-checkpoint-store-whitespace");
132+
const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
133+
const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
134+
135+
const componentPath = path.join(tmp, "Component.tsx");
136+
yield* writeTextFile(
137+
componentPath,
138+
[
139+
"export function View() {",
140+
" return (",
141+
" <section>",
142+
" <h1>Title</h1>",
143+
" <p>Body</p>",
144+
" </section>",
145+
" );",
146+
"}",
147+
"",
148+
].join("\n"),
149+
);
150+
yield* checkpointStore.captureCheckpoint({
151+
cwd: tmp,
152+
checkpointRef: fromCheckpointRef,
153+
});
154+
yield* writeTextFile(
155+
componentPath,
156+
[
157+
"export function View() {",
158+
" return (",
159+
" <section>",
160+
" {isReady ? (",
161+
" <div>",
162+
" <h1>Title</h1>",
163+
" <p>Body</p>",
164+
" </div>",
165+
" ) : null}",
166+
" </section>",
167+
" );",
168+
"}",
169+
"",
170+
].join("\n"),
171+
);
172+
yield* checkpointStore.captureCheckpoint({
173+
cwd: tmp,
174+
checkpointRef: toCheckpointRef,
175+
});
176+
177+
const normalDiff = yield* checkpointStore.diffCheckpoints({
178+
cwd: tmp,
179+
fromCheckpointRef,
180+
toCheckpointRef,
181+
ignoreWhitespace: false,
182+
});
183+
const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({
184+
cwd: tmp,
185+
fromCheckpointRef,
186+
toCheckpointRef,
187+
ignoreWhitespace: true,
188+
});
189+
190+
expect(normalDiff).toContain("diff --git");
191+
expect(normalDiff).toContain("- <h1>Title</h1>");
192+
expect(normalDiff).toContain("+ <h1>Title</h1>");
193+
expect(whitespaceIgnoredDiff).toContain("diff --git");
194+
expect(whitespaceIgnoredDiff).toContain("+ {isReady ? (");
195+
expect(whitespaceIgnoredDiff).toContain("+ <div>");
196+
expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>");
197+
expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>");
198+
}),
199+
);
124200
});
125201
});

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,20 @@ const makeCheckpointStore = Effect.gen(function* () {
259259
});
260260
}
261261

262+
const diffArgs = [
263+
"diff",
264+
"--patch",
265+
"--minimal",
266+
"--no-color",
267+
...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
268+
fromCommitOid,
269+
toCommitOid,
270+
];
271+
262272
const result = yield* vcs.execute({
263273
operation,
264274
cwd: input.cwd,
265-
args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
275+
args: diffArgs,
266276
maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
267277
});
268278

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface DiffCheckpointsInput {
3232
readonly fromCheckpointRef: CheckpointRef;
3333
readonly toCheckpointRef: CheckpointRef;
3434
readonly fallbackFromToHead?: boolean;
35+
readonly ignoreWhitespace: boolean;
3536
}
3637

3738
export interface DeleteCheckpointRefsInput {

apps/server/src/orchestration/Layers/CheckpointReactor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ const make = Effect.gen(function* () {
237237
fromCheckpointRef,
238238
toCheckpointRef: targetCheckpointRef,
239239
fallbackFromToHead: false,
240+
ignoreWhitespace: false,
240241
})
241242
.pipe(
242243
Effect.map((diff) =>

apps/web/src/components/DiffPanel.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ChevronLeftIcon,
99
ChevronRightIcon,
1010
Columns2Icon,
11+
PilcrowIcon,
1112
Rows3Icon,
1213
TextWrapIcon,
1314
} from "lucide-react";
@@ -172,6 +173,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
172173
const settings = useSettings();
173174
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
174175
const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
176+
const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
175177
const patchViewportRef = useRef<HTMLDivElement>(null);
176178
const turnStripRef = useRef<HTMLDivElement>(null);
177179
const previousDiffOpenRef = useRef(false);
@@ -277,6 +279,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
277279
threadId: activeThreadId,
278280
fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
279281
toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
282+
ignoreWhitespace: diffIgnoreWhitespace,
280283
cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
281284
enabled: isGitRepo,
282285
}),
@@ -317,9 +320,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
317320
useEffect(() => {
318321
if (diffOpen && !previousDiffOpenRef.current) {
319322
setDiffWordWrap(settings.diffWordWrap);
323+
setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace);
320324
}
321325
previousDiffOpenRef.current = diffOpen;
322-
}, [diffOpen, settings.diffWordWrap]);
326+
}, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]);
323327

324328
useEffect(() => {
325329
if (!selectedFilePath || !patchViewportRef.current) {
@@ -551,6 +555,18 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
551555
>
552556
<TextWrapIcon className="size-3" />
553557
</Toggle>
558+
<Toggle
559+
aria-label={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
560+
title={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
561+
variant="outline"
562+
size="xs"
563+
pressed={diffIgnoreWhitespace}
564+
onPressedChange={(pressed) => {
565+
setDiffIgnoreWhitespace(Boolean(pressed));
566+
}}
567+
>
568+
<PilcrowIcon className="size-3" />
569+
</Toggle>
554570
</div>
555571
</>
556572
);

apps/web/src/components/settings/SettingsPanels.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ export function useSettingsRestore(onRestored?: () => void) {
386386
...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
387387
? ["Diff line wrapping"]
388388
: []),
389+
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
390+
? ["Diff whitespace changes"]
391+
: []),
389392
...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
390393
? ["Auto-open task panel"]
391394
: []),
@@ -415,6 +418,7 @@ export function useSettingsRestore(onRestored?: () => void) {
415418
settings.confirmThreadDelete,
416419
settings.addProjectBaseDirectory,
417420
settings.defaultThreadEnvMode,
421+
settings.diffIgnoreWhitespace,
418422
settings.diffWordWrap,
419423
settings.enableAssistantStreaming,
420424
settings.timestampFormat,
@@ -889,6 +893,32 @@ export function GeneralSettingsPanel() {
889893
}
890894
/>
891895

896+
<SettingsRow
897+
title="Hide whitespace changes"
898+
description="Set whether the diff panel ignores whitespace-only edits by default."
899+
resetAction={
900+
settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? (
901+
<SettingResetButton
902+
label="diff whitespace changes"
903+
onClick={() =>
904+
updateSettings({
905+
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
906+
})
907+
}
908+
/>
909+
) : null
910+
}
911+
control={
912+
<Switch
913+
checked={settings.diffIgnoreWhitespace}
914+
onCheckedChange={(checked) =>
915+
updateSettings({ diffIgnoreWhitespace: Boolean(checked) })
916+
}
917+
aria-label="Hide whitespace changes by default"
918+
/>
919+
}
920+
/>
921+
892922
<SettingsRow
893923
title="Assistant output"
894924
description="Show token-by-token output while a response is in progress."

0 commit comments

Comments
 (0)