diff --git a/AGENTS.md b/AGENTS.md index 2f3ecbb9..4ad9984d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,8 +223,8 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | -| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, diffType, base, gitContext }` | -| `/api/diff/switch` | POST | Switch diff type and/or base branch (body: `{ diffType, base? }`) | +| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, diffType, base, hideWhitespace, gitContext }` | +| `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`) | | `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context (`?path=&oldPath=&base=`) | | `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) | | `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) | diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index a1ba0958..642349b2 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -500,8 +500,11 @@ if (args[0] === "sessions") { } else { // --- Local Review Mode --- gitContext = await getVcsContext(); - initialDiffType = gitContext.vcsType === "p4" ? "p4-default" : resolveDefaultDiffType(loadConfig()); - const diffResult = await runVcsDiff(initialDiffType, gitContext.defaultBranch); + const config = loadConfig(); + initialDiffType = gitContext.vcsType === "p4" ? "p4-default" : resolveDefaultDiffType(config); + const diffResult = await runVcsDiff(initialDiffType, gitContext.defaultBranch, undefined, { + hideWhitespace: config.diffOptions?.hideWhitespace ?? false, + }); rawPatch = diffResult.patch; gitRef = diffResult.label; diffError = diffResult.error; diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 3f31326b..15f87532 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -92,8 +92,11 @@ export async function handleReviewCommand( client.app.log({ level: "info", message: "Opening code review UI..." }); gitContext = await getGitContext(directory); - userDiffType = resolveDefaultDiffType(loadConfig()); - const diffResult = await runGitDiffWithContext(userDiffType, gitContext); + const config = loadConfig(); + userDiffType = resolveDefaultDiffType(config); + const diffResult = await runGitDiffWithContext(userDiffType, gitContext, { + hideWhitespace: config.diffOptions?.hideWhitespace ?? false, + }); rawPatch = diffResult.patch; gitRef = diffResult.label; diffError = diffResult.error; diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 418a0c8a..6b542257 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -352,8 +352,11 @@ export async function openCodeReview( const cwd = options.cwd ?? ctx.cwd; gitCtx = await getGitContext(cwd); const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch; - diffType = options.diffType ?? resolveDefaultDiffType(loadConfig()); - const result = await runGitDiff(diffType, defaultBranch, cwd); + const config = loadConfig(); + diffType = options.diffType ?? resolveDefaultDiffType(config); + const result = await runGitDiff(diffType, defaultBranch, cwd, { + hideWhitespace: config.diffOptions?.hideWhitespace ?? false, + }); rawPatch = result.patch; gitRef = result.label; diffError = result.error; diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 68944b32..8a6391b0 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -6,7 +6,7 @@ import os from "node:os"; import { Readable } from "node:stream"; import { contentHash, deleteDraft } from "../generated/draft.js"; -import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; +import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; export type { DiffOption, @@ -27,6 +27,7 @@ import { type DiffType, type GitCommandResult, type GitContext, + type GitDiffOptions, detectRemoteDefaultBranch, getFileContentsForDiff as getFileContentsForDiffCore, getGitContext as getGitContextCore, @@ -174,8 +175,9 @@ export function runGitDiff( diffType: DiffType, defaultBranch = "main", cwd?: string, + options?: GitDiffOptions, ): Promise<{ patch: string; label: string; error?: string }> { - return runGitDiffCore(reviewRuntime, diffType, defaultBranch, cwd); + return runGitDiffCore(reviewRuntime, diffType, defaultBranch, cwd, options); } export async function startReviewServer(options: { @@ -269,6 +271,7 @@ export async function startReviewServer(options: { let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; + let currentHideWhitespace = loadConfig().diffOptions?.hideWhitespace ?? false; let originalPRPatch = options.rawPatch; let originalPRGitRef = options.gitRef; let originalPRError = options.error; @@ -606,6 +609,7 @@ export async function startReviewServer(options: { // Echo the active base so page refresh/reconnect rehydrates the // picker to what the server is actually using, not the detected default. base: hasLocalAccess ? currentBase : undefined, + hideWhitespace: currentHideWhitespace, gitContext: hasLocalAccess ? options.gitContext : undefined, sharingEnabled, shareBaseUrl, @@ -637,13 +641,18 @@ export async function startReviewServer(options: { json(res, { error: "Missing diffType" }, 400); return; } + if (typeof body.hideWhitespace === "boolean") { + currentHideWhitespace = body.hideWhitespace; + } const detectedBase = options.gitContext?.defaultBranch || "main"; const base = resolveBaseBranch( typeof body.base === "string" ? body.base : undefined, detectedBase, ); const defaultCwd = options.gitContext?.cwd; - const result = await runGitDiff(newType, base, defaultCwd); + const result = await runGitDiff(newType, base, defaultCwd, { + hideWhitespace: currentHideWhitespace, + }); currentPatch = result.patch; currentGitRef = result.label; currentDiffType = newType; @@ -674,6 +683,7 @@ export async function startReviewServer(options: { // confirm the request landed (and pick it up when the client // didn't supply one and we fell back to detected default). base: currentBase, + hideWhitespace: currentHideWhitespace, ...(updatedContext ? { gitContext: updatedContext } : {}), ...(currentError ? { error: currentError } : {}), }); diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 9775399a..57db41be 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -1016,7 +1016,7 @@ const ReviewApp: React.FC = () => { // Shared helper: fetch a diff switch and update state. // Returns true on success, false on failure — callers that optimistically // updated UI state (e.g. the base picker) can use this to revert. - const fetchDiffSwitch = useCallback(async (fullDiffType: string, baseOverride?: string): Promise => { + const fetchDiffSwitch = useCallback(async (fullDiffType: string, baseOverride?: string, options?: { preserveFile?: boolean }): Promise => { setIsLoadingDiff(true); try { const res = await fetch('/api/diff/switch', { @@ -1027,6 +1027,7 @@ const ReviewApp: React.FC = () => { // Server ignores base for modes that don't use it (uncommitted/staged/etc), // so forwarding unconditionally is safe and keeps the request shape uniform. ...((baseOverride ?? selectedBase) && { base: baseOverride ?? selectedBase }), + hideWhitespace: diffHideWhitespace, }), }); @@ -1042,46 +1043,59 @@ const ReviewApp: React.FC = () => { }; const nextFiles = parseDiffToFiles(data.rawPatch); - dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close(); - needsInitialDiffPanel.current = true; - setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef, diffType: data.diffType } : prev); - setFiles(nextFiles); - setDiffType(data.diffType); - // Adopt the server's echoed base. The server trusts whatever we sent - // verbatim — this sync just makes sure selectedBase and committedBase - // match the server's view (important when the caller didn't send a - // base and the server used the detected default instead). - if (data.base) { - setSelectedBase(data.base); - setCommittedBase(data.base); + + if (options?.preserveFile) { + // Whitespace toggle: update patch in-place, keep the active file. + // If the current file was removed (whitespace-only), retarget the + // dock panel to the first remaining file. + setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef } : prev); + setFiles(nextFiles); + const currentPath = files[activeFileIndex]?.path; + const nextIdx = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1; + if (nextIdx !== -1) { + setActiveFileIndex(nextIdx); + } else if (nextFiles.length > 0) { + setActiveFileIndex(0); + openDiffFile(nextFiles[0].path); + } + } else { + dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close(); + needsInitialDiffPanel.current = true; + setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef, diffType: data.diffType } : prev); + setFiles(nextFiles); + setDiffType(data.diffType); + if (data.base) { + setSelectedBase(data.base); + setCommittedBase(data.base); + } + // Merge only the per-cwd fields so the sidebar reflects the worktree + // we're now in. Keep the original `worktrees` list (already filtered to + // exclude the server's startup cwd — replacing it with the new context's + // list would duplicate the "Main repo" entry) and `availableBranches` + // (shared across worktrees of the same repo). + // + // IMPORTANT: we deliberately do NOT overwrite `currentBranch`. The + // WorktreePicker's top "launch" row uses it as a label, and that row + // represents the cwd plannotator was launched in — not whichever + // worktree is currently active. Freezing `currentBranch` at its + // initial-load value keeps that label truthful. `defaultBranch` and + // `diffOptions` update because they describe the active diff, which + // other UI (empty-state text, diff-type picker) should see fresh. + if (data.gitContext) { + setGitContext((prev) => { + if (!prev) return data.gitContext!; + return { + ...prev, + defaultBranch: data.gitContext!.defaultBranch, + diffOptions: data.gitContext!.diffOptions, + }; + }); + } + setActiveFileIndex(0); + setPendingSelection(null); + resetStagedFiles(); } - // Merge only the per-cwd fields so the sidebar reflects the worktree - // we're now in. Keep the original `worktrees` list (already filtered to - // exclude the server's startup cwd — replacing it with the new context's - // list would duplicate the "Main repo" entry) and `availableBranches` - // (shared across worktrees of the same repo). - // - // IMPORTANT: we deliberately do NOT overwrite `currentBranch`. The - // WorktreePicker's top "launch" row uses it as a label, and that row - // represents the cwd plannotator was launched in — not whichever - // worktree is currently active. Freezing `currentBranch` at its - // initial-load value keeps that label truthful. `defaultBranch` and - // `diffOptions` update because they describe the active diff, which - // other UI (empty-state text, diff-type picker) should see fresh. - if (data.gitContext) { - setGitContext((prev) => { - if (!prev) return data.gitContext!; - return { - ...prev, - defaultBranch: data.gitContext!.defaultBranch, - diffOptions: data.gitContext!.diffOptions, - }; - }); - } - setActiveFileIndex(0); - setPendingSelection(null); setDiffError(data.error || null); - resetStagedFiles(); return true; } catch (err) { console.error('Failed to switch diff:', err); @@ -1090,7 +1104,7 @@ const ReviewApp: React.FC = () => { } finally { setIsLoadingDiff(false); } - }, [dockApi, resetStagedFiles, selectedBase]); + }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile]); // Switch the base branch the current diff compares against. // Only triggers a refetch when the active mode actually uses a base. @@ -1130,6 +1144,18 @@ const ReviewApp: React.FC = () => { await fetchDiffSwitch(fullDiffType); }, [activeWorktreePath, activeDiffBase, fetchDiffSwitch]); + // Re-fetch diff when hideWhitespace toggles so the server applies git diff -w. + // Preserves the active file since only whitespace hunks change. + const hideWhitespaceInitialized = useRef(false); + useEffect(() => { + if (!origin || !gitContext) return; + if (!hideWhitespaceInitialized.current) { + hideWhitespaceInitialized.current = true; + return; + } + fetchDiffSwitch(diffType, selectedBase, { preserveFile: true }); + }, [diffHideWhitespace, origin]); // eslint-disable-line react-hooks/exhaustive-deps + // Select annotation - switches file if needed and scrolls to it const handleSelectAnnotation = useCallback((id: string | null) => { if (!id) { @@ -1192,7 +1218,6 @@ const ReviewApp: React.FC = () => { lineDiffType: diffLineDiffType, disableLineNumbers: !diffShowLineNumbers, disableBackground: !diffShowBackground, - hideWhitespace: diffHideWhitespace, fontFamily: diffFontFamily || undefined, fontSize: diffFontSize || undefined, // Only propagate base for modes where it affects old/new content. Avoids @@ -1249,7 +1274,7 @@ const ReviewApp: React.FC = () => { openTourPanel: handleOpenTour, }), [ files, activeFileIndex, diffStyle, diffOverflow, diffIndicators, - diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffHideWhitespace, + diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, allAnnotations, externalAnnotations, selectedAnnotationId, pendingSelection, handleLineSelection, diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/review-editor/components/DiffViewer.tsx index 623a3135..f0a4d85a 100644 --- a/packages/review-editor/components/DiffViewer.tsx +++ b/packages/review-editor/components/DiffViewer.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react'; import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react'; -import { getSingularPatch, processFile, parseDiffFromFile } from '@pierre/diffs'; +import { getSingularPatch, processFile } from '@pierre/diffs'; import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; import type { DiffTokenEventBaseProps } from '@pierre/diffs'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; @@ -127,7 +127,6 @@ interface DiffViewerProps { lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; disableLineNumbers?: boolean; disableBackground?: boolean; - hideWhitespace?: boolean; fontFamily?: string; fontSize?: string; annotations: CodeAnnotation[]; @@ -173,7 +172,6 @@ export const DiffViewer: React.FC = ({ lineDiffType, disableLineNumbers, disableBackground, - hideWhitespace, fontFamily, fontSize, annotations, @@ -294,18 +292,9 @@ export const DiffViewer: React.FC = ({ // Re-parse the patch with full file contents so hunk indices are computed // against the complete file (isPartial: false), enabling expansion. - // When hideWhitespace is on, recompute the diff from file contents to - // exclude whitespace-only changes (like GitHub's ?w=1). const augmentedDiff = useMemo(() => { if (!fileContents || fileContents.forPath !== filePath || (fileContents.old == null && fileContents.new == null)) return fileDiff; try { - if (hideWhitespace && fileContents.old != null && fileContents.new != null) { - const normalize = (s: string) => s.split('\n').map(l => l.replace(/\s+/g, ' ').trim()).join('\n'); - return parseDiffFromFile( - { name: oldPath || filePath, contents: normalize(fileContents.old) }, - { name: filePath, contents: normalize(fileContents.new) }, - ); - } const result = processFile(patch, { oldFile: fileContents.old != null ? { name: oldPath || filePath, contents: fileContents.old } : undefined, newFile: fileContents.new != null ? { name: filePath, contents: fileContents.new } : undefined, @@ -314,7 +303,7 @@ export const DiffViewer: React.FC = ({ } catch { return fileDiff; } - }, [patch, filePath, oldPath, fileContents, fileDiff, hideWhitespace]); + }, [patch, filePath, oldPath, fileContents, fileDiff]); const previousScrollFilePathRef = useRef(filePath); useLayoutEffect(() => { diff --git a/packages/review-editor/dock/ReviewStateContext.tsx b/packages/review-editor/dock/ReviewStateContext.tsx index 1f71d6cb..2ca097ae 100644 --- a/packages/review-editor/dock/ReviewStateContext.tsx +++ b/packages/review-editor/dock/ReviewStateContext.tsx @@ -26,7 +26,6 @@ export interface ReviewState { lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; disableLineNumbers?: boolean; disableBackground?: boolean; - hideWhitespace?: boolean; fontFamily?: string; fontSize?: string; /** User-selected base branch; feeds the `base` query param on file-content fetches. */ diff --git a/packages/review-editor/dock/panels/ReviewDiffPanel.tsx b/packages/review-editor/dock/panels/ReviewDiffPanel.tsx index 7933648c..7de62e3e 100644 --- a/packages/review-editor/dock/panels/ReviewDiffPanel.tsx +++ b/packages/review-editor/dock/panels/ReviewDiffPanel.tsx @@ -78,7 +78,6 @@ export const ReviewDiffPanel: React.FC = (props) => { lineDiffType={state.lineDiffType} disableLineNumbers={state.disableLineNumbers} disableBackground={state.disableBackground} - hideWhitespace={state.hideWhitespace} fontFamily={state.fontFamily} fontSize={state.fontSize} annotations={fileAnnotations} diff --git a/packages/server/git.ts b/packages/server/git.ts index d6ccbc23..ea94dff5 100644 --- a/packages/server/git.ts +++ b/packages/server/git.ts @@ -11,6 +11,7 @@ import { type DiffType, type GitCommandResult, type GitContext, + type GitDiffOptions, type ReviewGitRuntime, type WorktreeInfo, getCurrentBranch as getCurrentBranchCore, @@ -31,6 +32,7 @@ export type { DiffType, DiffResult, GitContext, + GitDiffOptions, WorktreeInfo, } from "@plannotator/shared/review-core"; @@ -92,15 +94,17 @@ export function runGitDiff( diffType: DiffType, defaultBranch: string = "main", cwd?: string, + options?: GitDiffOptions, ): Promise { - return runGitDiffCore(runtime, diffType, defaultBranch, cwd); + return runGitDiffCore(runtime, diffType, defaultBranch, cwd, options); } export function runGitDiffWithContext( diffType: DiffType, gitContext: GitContext, + options?: GitDiffOptions, ): Promise { - return runGitDiffWithContextCore(runtime, diffType, gitContext); + return runGitDiffWithContextCore(runtime, diffType, gitContext, options); } export function getFileContentsForDiff( diff --git a/packages/server/review.ts b/packages/server/review.ts index 08403418..d588186b 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -44,7 +44,7 @@ import { transformClaudeFindings, } from "./claude-review"; import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "./tour/tour-review"; -import { saveConfig, detectGitUser, getServerConfig } from "./config"; +import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "./config"; import { type PRMetadata, type PRReviewFileComment, type PRStackTree, type PRListItem, fetchPR, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, fetchPRStack, fetchPRList, getPRUser, parsePRUrl, prRefFromMetadata, isSameProject, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr"; import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai"; import { isWSL } from "./browser"; @@ -150,6 +150,7 @@ export async function startReviewServer( let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; + let currentHideWhitespace = loadConfig().diffOptions?.hideWhitespace ?? false; let originalPRPatch = options.rawPatch; let originalPRGitRef = options.gitRef; let originalPRError = options.error; @@ -515,6 +516,7 @@ export async function startReviewServer( // the picker to what the server is actually using — not the // detected default. base: hasLocalAccess ? currentBase : undefined, + hideWhitespace: currentHideWhitespace, gitContext: hasLocalAccess ? gitContext : undefined, sharingEnabled, shareBaseUrl, @@ -544,7 +546,7 @@ export async function startReviewServer( ); } try { - const body = (await req.json()) as { diffType: DiffType; base?: string }; + const body = (await req.json()) as { diffType: DiffType; base?: string; hideWhitespace?: boolean }; let newDiffType = body.diffType; if (!newDiffType) { @@ -554,6 +556,10 @@ export async function startReviewServer( ); } + if (typeof body.hideWhitespace === "boolean") { + currentHideWhitespace = body.hideWhitespace; + } + const detectedBase = gitContext?.defaultBranch || "main"; // Guard against non-string payloads — resolveBaseBranch calls // string methods and would throw a TypeError otherwise. Mirrors @@ -563,7 +569,9 @@ export async function startReviewServer( const defaultCwd = gitContext?.cwd; // Run the new diff - const result = await runVcsDiff(newDiffType, base, defaultCwd); + const result = await runVcsDiff(newDiffType, base, defaultCwd, { + hideWhitespace: currentHideWhitespace, + }); // Update state currentPatch = result.patch; @@ -597,6 +605,7 @@ export async function startReviewServer( // confirm the request landed (and pick it up when the client // didn't supply one and we fell back to detected default). base: currentBase, + hideWhitespace: currentHideWhitespace, ...(updatedContext && { gitContext: updatedContext }), ...(currentError && { error: currentError }), }); diff --git a/packages/server/vcs.ts b/packages/server/vcs.ts index 53f0d2c4..fab8cddd 100644 --- a/packages/server/vcs.ts +++ b/packages/server/vcs.ts @@ -14,6 +14,7 @@ import { type DiffResult, type DiffType, type GitContext, + type GitDiffOptions, } from "@plannotator/shared/review-core"; import { @@ -49,7 +50,7 @@ export interface VcsProvider { getContext(cwd?: string): Promise; /** Get unified diff patch for the given diff type */ - runDiff(diffType: DiffType, defaultBranch: string, cwd?: string): Promise; + runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions): Promise; /** Get old/new file contents for hunk expansion */ getFileContents( @@ -97,8 +98,8 @@ const gitProvider: VcsProvider = { getContext: getGitContext, - runDiff(diffType: DiffType, defaultBranch: string, cwd?: string) { - return runGitDiff(diffType, defaultBranch, cwd); + runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions) { + return runGitDiff(diffType, defaultBranch, cwd, options); }, getFileContents(diffType, defaultBranch, filePath, oldPath?, cwd?) { @@ -132,7 +133,7 @@ const p4Provider: VcsProvider = { getContext: getP4Context, - runDiff(diffType: DiffType, _defaultBranch: string, cwd?: string) { + runDiff(diffType: DiffType, _defaultBranch: string, cwd?: string, _options?: GitDiffOptions) { return runP4Diff(diffType, cwd); }, @@ -199,9 +200,10 @@ export async function runVcsDiff( diffType: DiffType, defaultBranch: string = "main", cwd?: string, + options?: GitDiffOptions, ): Promise { const provider = getProviderForDiffType(diffType); - return provider.runDiff(diffType, defaultBranch, cwd); + return provider.runDiff(diffType, defaultBranch, cwd, options); } export async function getVcsFileContentsForDiff( diff --git a/packages/shared/config.ts b/packages/shared/config.ts index d463646e..494a95ce 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -21,6 +21,7 @@ export interface DiffOptions { showDiffBackground?: boolean; fontFamily?: string; fontSize?: string; + hideWhitespace?: boolean; defaultDiffType?: DefaultDiffType; } diff --git a/packages/shared/review-core.ts b/packages/shared/review-core.ts index 5b5548c6..bf580fc5 100644 --- a/packages/shared/review-core.ts +++ b/packages/shared/review-core.ts @@ -65,6 +65,10 @@ export interface ReviewGitRuntime { readTextFile: (path: string) => Promise; } +export interface GitDiffOptions { + hideWhitespace?: boolean; +} + export async function getCurrentBranch( runtime: ReviewGitRuntime, cwd?: string, @@ -303,6 +307,7 @@ async function getUntrackedFileDiffs( srcPrefix = "a/", dstPrefix = "b/", cwd?: string, + options?: GitDiffOptions, ): Promise { // git ls-files scopes to the CWD subtree and returns CWD-relative paths, // unlike git diff HEAD which always covers the full repo with root-relative @@ -334,6 +339,7 @@ async function getUntrackedFileDiffs( [ "diff", "--no-ext-diff", + ...(options?.hideWhitespace ? ["-w"] : []), "--no-index", `--src-prefix=${srcPrefix}`, `--dst-prefix=${dstPrefix}`, @@ -396,6 +402,7 @@ export async function runGitDiff( diffType: DiffType, defaultBranch: string = "main", externalCwd?: string, + options?: GitDiffOptions, ): Promise { let patch = ""; let label = ""; @@ -415,12 +422,15 @@ export async function runGitDiff( effectiveDiffType = parsed.subType; } + const wFlag = options?.hideWhitespace ? ["-w"] : []; + try { switch (effectiveDiffType) { case "uncommitted": { const trackedDiffArgs = [ "diff", "--no-ext-diff", + ...wFlag, "HEAD", "--src-prefix=a/", "--dst-prefix=b/", @@ -439,6 +449,7 @@ export async function runGitDiff( "a/", "b/", cwd, + options, ); patch = trackedPatch + untrackedDiff; label = "Uncommitted changes"; @@ -449,6 +460,7 @@ export async function runGitDiff( const stagedDiffArgs = [ "diff", "--no-ext-diff", + ...wFlag, "--staged", "--src-prefix=a/", "--dst-prefix=b/", @@ -463,7 +475,13 @@ export async function runGitDiff( } case "unstaged": { - const trackedDiffArgs = ["diff", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/"]; + const trackedDiffArgs = [ + "diff", + "--no-ext-diff", + ...wFlag, + "--src-prefix=a/", + "--dst-prefix=b/", + ]; const trackedDiff = assertGitSuccess( await runtime.runGit(trackedDiffArgs, { cwd }), trackedDiffArgs, @@ -473,6 +491,7 @@ export async function runGitDiff( "a/", "b/", cwd, + options, ); patch = trackedDiff.stdout + untrackedDiff; label = "Unstaged changes"; @@ -486,8 +505,8 @@ export async function runGitDiff( ); const args = hasParent.exitCode === 0 - ? ["diff", "--no-ext-diff", "HEAD~1..HEAD", "--src-prefix=a/", "--dst-prefix=b/"] - : ["diff", "--no-ext-diff", "--root", "HEAD", "--src-prefix=a/", "--dst-prefix=b/"]; + ? ["diff", "--no-ext-diff", ...wFlag, "HEAD~1..HEAD", "--src-prefix=a/", "--dst-prefix=b/"] + : ["diff", "--no-ext-diff", ...wFlag, "--root", "HEAD", "--src-prefix=a/", "--dst-prefix=b/"]; const lastCommitDiff = assertGitSuccess( await runtime.runGit(args, { cwd }), args, @@ -505,6 +524,7 @@ export async function runGitDiff( const branchDiffArgs = [ "diff", "--no-ext-diff", + ...wFlag, "--src-prefix=a/", "--dst-prefix=b/", "--end-of-options", @@ -529,6 +549,7 @@ export async function runGitDiff( const mergeBaseDiffArgs = [ "diff", "--no-ext-diff", + ...wFlag, "--src-prefix=a/", "--dst-prefix=b/", "--end-of-options", @@ -552,6 +573,7 @@ export async function runGitDiff( const allDiffArgs = [ "diff", "--no-ext-diff", + ...wFlag, "--src-prefix=a/", "--dst-prefix=b/", "--end-of-options", @@ -597,8 +619,9 @@ export async function runGitDiffWithContext( runtime: ReviewGitRuntime, diffType: DiffType, gitContext: GitContext, + options?: GitDiffOptions, ): Promise { - return runGitDiff(runtime, diffType, gitContext.defaultBranch, gitContext.cwd); + return runGitDiff(runtime, diffType, gitContext.defaultBranch, gitContext.cwd, options); } export async function getFileContentsForDiff(