Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -111,12 +111,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 @@ -243,10 +243,20 @@ const makeCheckpointStore = Effect.gen(function* () {
});
}

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

const result = yield* git.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 @@ -433,6 +433,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
? ["Task sidebar"]
: []),
Expand Down Expand Up @@ -462,6 +465,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.diffIgnoreWhitespace,
settings.diffWordWrap,
settings.enableAssistantStreaming,
settings.timestampFormat,
Expand Down Expand Up @@ -936,6 +940,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