Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
confirmThreadArchive: true,
confirmThreadDelete: false,
diffIgnoreWhitespace: true,
diffWordWrap: true,
favorites: [],
providerModelPreferences: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1),
toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
fallbackFromToHead: false,
ignoreWhitespace: false,
});
assert.equal(incrementalDiff.includes("README.md"), true);

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

Expand Down
73 changes: 71 additions & 2 deletions apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe("CheckpointDiffQueryLive", () => {
readonly fromCheckpointRef: CheckpointRef;
readonly toCheckpointRef: CheckpointRef;
readonly cwd: string;
readonly ignoreWhitespace: boolean;
}> = [];

const threadCheckpointContext = makeThreadCheckpointContext({
Expand All @@ -68,9 +69,14 @@ describe("CheckpointDiffQueryLive", () => {
return true;
}),
restoreCheckpoint: () => Effect.succeed(true),
diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) =>
diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd, ignoreWhitespace }) =>
Effect.sync(() => {
diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd });
diffCheckpointsCalls.push({
fromCheckpointRef,
toCheckpointRef,
cwd,
ignoreWhitespace,
});
return "diff patch";
}),
deleteCheckpointRefs: () => Effect.void,
Expand Down Expand Up @@ -102,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => {
threadId,
fromTurnCount: 0,
toTurnCount: 1,
ignoreWhitespace: true,
});
}).pipe(Effect.provide(layer)),
);
Expand All @@ -113,6 +120,7 @@ describe("CheckpointDiffQueryLive", () => {
cwd: "/tmp/workspace",
fromCheckpointRef: expectedFromRef,
toCheckpointRef,
ignoreWhitespace: true,
},
]);
expect(result).toEqual({
Expand All @@ -123,6 +131,67 @@ describe("CheckpointDiffQueryLive", () => {
});
});

it("defaults to hide whitespace changes", async () => {
const projectId = ProjectId.make("project-default-whitespace");
const threadId = ThreadId.make("thread-default-whitespace");
const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
const diffCheckpointsCalls: Array<{ readonly ignoreWhitespace: boolean }> = [];

const threadCheckpointContext = makeThreadCheckpointContext({
projectId,
threadId,
workspaceRoot: "/tmp/workspace",
worktreePath: null,
checkpointTurnCount: 1,
checkpointRef: toCheckpointRef,
});

const checkpointStore: CheckpointStoreShape = {
isGitRepository: () => Effect.succeed(true),
captureCheckpoint: () => Effect.void,
hasCheckpointRef: () => Effect.succeed(true),
restoreCheckpoint: () => Effect.succeed(true),
diffCheckpoints: ({ ignoreWhitespace }) =>
Effect.sync(() => {
diffCheckpointsCalls.push({ ignoreWhitespace });
return "diff patch";
}),
deleteCheckpointRefs: () => Effect.void,
};

const layer = CheckpointDiffQueryLive.pipe(
Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
Layer.provideMerge(
Layer.succeed(ProjectionSnapshotQuery, {
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
}),
),
);

await Effect.runPromise(
Effect.gen(function* () {
const query = yield* CheckpointDiffQuery;
return yield* query.getTurnDiff({
threadId,
fromTurnCount: 0,
toTurnCount: 1,
});
}).pipe(Effect.provide(layer)),
);

expect(diffCheckpointsCalls).toEqual([{ ignoreWhitespace: true }]);
});

it("fails when the thread is missing from the snapshot", async () => {
const threadId = ThreadId.make("thread-missing");

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const make = Effect.gen(function* () {
const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = Effect.fn("getTurnDiff")(
function* (input) {
const operation = "CheckpointDiffQuery.getTurnDiff";
const ignoreWhitespace = input.ignoreWhitespace ?? true;

if (input.fromTurnCount === input.toTurnCount) {
const emptyDiff: OrchestrationGetTurnDiffResultType = {
Expand Down Expand Up @@ -131,6 +132,7 @@ const make = Effect.gen(function* () {
fromCheckpointRef,
toCheckpointRef,
fallbackFromToHead: false,
ignoreWhitespace,
});

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

return {
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/checkpointing/Layers/CheckpointStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,88 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {
cwd: tmp,
fromCheckpointRef,
toCheckpointRef,
ignoreWhitespace: true,
});

expect(diff).toContain("diff --git");
expect(diff).not.toContain("[truncated]");
expect(diff).toContain("+line 04999");
}),
);

it.effect("can hide indentation churn when changes wrap existing lines", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const checkpointStore = yield* CheckpointStore;
const threadId = ThreadId.make("thread-checkpoint-store-whitespace");
const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);

const componentPath = path.join(tmp, "Component.tsx");
yield* writeTextFile(
componentPath,
[
"export function View() {",
" return (",
" <section>",
" <h1>Title</h1>",
" <p>Body</p>",
" </section>",
" );",
"}",
"",
].join("\n"),
);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: fromCheckpointRef,
});
yield* writeTextFile(
componentPath,
[
"export function View() {",
" return (",
" <section>",
" {isReady ? (",
" <div>",
" <h1>Title</h1>",
" <p>Body</p>",
" </div>",
" ) : null}",
" </section>",
" );",
"}",
"",
].join("\n"),
);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: toCheckpointRef,
});

const normalDiff = yield* checkpointStore.diffCheckpoints({
cwd: tmp,
fromCheckpointRef,
toCheckpointRef,
ignoreWhitespace: false,
});
const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({
cwd: tmp,
fromCheckpointRef,
toCheckpointRef,
ignoreWhitespace: true,
});

expect(normalDiff).toContain("diff --git");
expect(normalDiff).toContain("- <h1>Title</h1>");
expect(normalDiff).toContain("+ <h1>Title</h1>");
expect(whitespaceIgnoredDiff).toContain("diff --git");
expect(whitespaceIgnoredDiff).toContain("+ {isReady ? (");
expect(whitespaceIgnoredDiff).toContain("+ <div>");
expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>");
expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>");
}),
);
});
});
12 changes: 11 additions & 1 deletion apps/server/src/checkpointing/Layers/CheckpointStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,20 @@ const makeCheckpointStore = Effect.gen(function* () {
});
}

const diffArgs = [
"diff",
"--patch",
"--minimal",
"--no-color",
...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
fromCommitOid,
toCommitOid,
];

const result = yield* vcs.execute({
operation,
cwd: input.cwd,
args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
args: diffArgs,
maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/checkpointing/Services/CheckpointStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface DiffCheckpointsInput {
readonly fromCheckpointRef: CheckpointRef;
readonly toCheckpointRef: CheckpointRef;
readonly fallbackFromToHead?: boolean;
readonly ignoreWhitespace: boolean;
}

export interface DeleteCheckpointRefsInput {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ const make = Effect.gen(function* () {
fromCheckpointRef,
toCheckpointRef: targetCheckpointRef,
fallbackFromToHead: false,
ignoreWhitespace: false,
})
.pipe(
Effect.map((diff) =>
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
Columns2Icon,
PilcrowIcon,
Rows3Icon,
TextWrapIcon,
} from "lucide-react";
Expand Down Expand Up @@ -172,6 +173,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const settings = useSettings();
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
const patchViewportRef = useRef<HTMLDivElement>(null);
const turnStripRef = useRef<HTMLDivElement>(null);
const previousDiffOpenRef = useRef(false);
Expand Down Expand Up @@ -277,6 +279,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
threadId: activeThreadId,
fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
ignoreWhitespace: diffIgnoreWhitespace,
cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
enabled: isGitRepo,
}),
Expand Down Expand Up @@ -317,9 +320,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
useEffect(() => {
if (diffOpen && !previousDiffOpenRef.current) {
setDiffWordWrap(settings.diffWordWrap);
setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace);
}
previousDiffOpenRef.current = diffOpen;
}, [diffOpen, settings.diffWordWrap]);
}, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]);

useEffect(() => {
if (!selectedFilePath || !patchViewportRef.current) {
Expand Down Expand Up @@ -551,6 +555,18 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
>
<TextWrapIcon className="size-3" />
</Toggle>
<Toggle
aria-label={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
title={diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
variant="outline"
size="xs"
pressed={diffIgnoreWhitespace}
onPressedChange={(pressed) => {
setDiffIgnoreWhitespace(Boolean(pressed));
}}
>
<PilcrowIcon className="size-3" />
</Toggle>
</div>
</>
);
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
? ["Diff line wrapping"]
: []),
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
? ["Diff whitespace changes"]
: []),
...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
? ["Auto-open task panel"]
: []),
Expand Down Expand Up @@ -415,6 +418,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.diffIgnoreWhitespace,
settings.diffWordWrap,
settings.enableAssistantStreaming,
settings.timestampFormat,
Expand Down Expand Up @@ -889,6 +893,32 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Hide whitespace changes"
description="Set whether the diff panel ignores whitespace-only edits by default."
resetAction={
settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? (
<SettingResetButton
label="diff whitespace changes"
onClick={() =>
updateSettings({
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
})
}
/>
) : null
}
control={
<Switch
checked={settings.diffIgnoreWhitespace}
onCheckedChange={(checked) =>
updateSettings({ diffIgnoreWhitespace: Boolean(checked) })
}
aria-label="Hide whitespace changes by default"
/>
}
/>

<SettingsRow
title="Assistant output"
description="Show token-by-token output while a response is in progress."
Expand Down
Loading
Loading