Skip to content

Commit b4b1d43

Browse files
backnotpropclaude
andauthored
feat(review): diff display options — hide whitespace + quick-settings popover (#631)
* feat(review): add hide whitespace setting for diffs Adds a "Hide Whitespace" toggle that suppresses whitespace-only changes in diffs, matching GitHub's ?w=1 behavior. Uses parseDiffFromFile with ignoreWhitespace when full file contents are available. Includes demo data with a whitespace-heavy file (settings.ts) and a vite dev server plugin to serve file contents in demo mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(review): add quick-settings popover on file header Adds a gear icon to the file header that opens a compact Radix popover with all diff display options (style, overflow, indicators, inline diff, line numbers, background, hide whitespace). Provides quick access to settings without opening the full settings dialog. Exports option constants from Settings.tsx for reuse. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b017b29 commit b4b1d43

10 files changed

Lines changed: 347 additions & 10 deletions

File tree

apps/review/vite.config.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
import path from 'path';
2-
import { defineConfig } from 'vite';
2+
import { defineConfig, type Plugin } from 'vite';
33
import react from '@vitejs/plugin-react';
44
import { viteSingleFile } from 'vite-plugin-singlefile';
55
import tailwindcss from '@tailwindcss/vite';
66
import pkg from '../../package.json';
7+
import { DEMO_FILE_CONTENTS } from '../../packages/review-editor/demoData';
8+
9+
function demoFileContentPlugin(): Plugin {
10+
return {
11+
name: 'demo-file-content',
12+
configureServer(server) {
13+
server.middlewares.use((req, res, next) => {
14+
if (!req.url?.startsWith('/api/file-content')) return next();
15+
const filePath = new URL(req.url, 'http://localhost').searchParams.get('path');
16+
const entry = filePath ? DEMO_FILE_CONTENTS[filePath] : undefined;
17+
if (!entry) return next();
18+
res.setHeader('Content-Type', 'application/json');
19+
res.end(JSON.stringify({ oldContent: entry.oldContent, newContent: entry.newContent }));
20+
});
21+
},
22+
};
23+
}
724

825
export default defineConfig({
926
server: {
@@ -13,7 +30,7 @@ export default defineConfig({
1330
define: {
1431
__APP_VERSION__: JSON.stringify(pkg.version),
1532
},
16-
plugins: [react(), tailwindcss(), viteSingleFile()],
33+
plugins: [demoFileContentPlugin(), react(), tailwindcss(), viteSingleFile()],
1734
resolve: {
1835
alias: {
1936
'@': path.resolve(__dirname, '.'),

packages/review-editor/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ const ReviewApp: React.FC = () => {
142142
const diffLineDiffType = useConfigValue('diffLineDiffType');
143143
const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
144144
const diffShowBackground = useConfigValue('diffShowBackground');
145+
const diffHideWhitespace = useConfigValue('diffHideWhitespace');
145146
const diffFontFamily = useConfigValue('diffFontFamily');
146147
const diffFontSize = useConfigValue('diffFontSize');
147148

@@ -1191,6 +1192,7 @@ const ReviewApp: React.FC = () => {
11911192
lineDiffType: diffLineDiffType,
11921193
disableLineNumbers: !diffShowLineNumbers,
11931194
disableBackground: !diffShowBackground,
1195+
hideWhitespace: diffHideWhitespace,
11941196
fontFamily: diffFontFamily || undefined,
11951197
fontSize: diffFontSize || undefined,
11961198
// Only propagate base for modes where it affects old/new content. Avoids
@@ -1247,7 +1249,7 @@ const ReviewApp: React.FC = () => {
12471249
openTourPanel: handleOpenTour,
12481250
}), [
12491251
files, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
1250-
diffLineDiffType, diffShowLineNumbers, diffShowBackground,
1252+
diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffHideWhitespace,
12511253
diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope,
12521254
allAnnotations, externalAnnotations,
12531255
selectedAnnotationId, pendingSelection, handleLineSelection,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import React from 'react';
2+
import * as Popover from '@radix-ui/react-popover';
3+
import { configStore, useConfigValue } from '@plannotator/ui/config';
4+
import {
5+
DIFF_STYLE_OPTIONS,
6+
OVERFLOW_OPTIONS,
7+
INDICATOR_OPTIONS,
8+
LINE_DIFF_OPTIONS,
9+
} from '@plannotator/ui/components/Settings';
10+
11+
function CompactSegmented<T extends string>({ options, value, onChange }: {
12+
options: { value: T; label: string }[];
13+
value: T;
14+
onChange: (v: T) => void;
15+
}) {
16+
return (
17+
<div className="flex items-center gap-px bg-muted/60 rounded-md p-px">
18+
{options.map((opt) => (
19+
<button
20+
key={opt.value}
21+
onClick={() => onChange(opt.value)}
22+
className={`flex-1 px-2 py-1 text-[11px] rounded-[5px] transition-colors ${
23+
value === opt.value
24+
? 'bg-background text-foreground shadow-sm font-medium'
25+
: 'text-muted-foreground hover:text-foreground'
26+
}`}
27+
>
28+
{opt.label}
29+
</button>
30+
))}
31+
</div>
32+
);
33+
}
34+
35+
function CompactToggle({ checked, onChange, label }: {
36+
checked: boolean;
37+
onChange: (v: boolean) => void;
38+
label: string;
39+
}) {
40+
return (
41+
<button
42+
role="switch"
43+
aria-checked={checked}
44+
onClick={() => onChange(!checked)}
45+
className="w-full flex items-center justify-between py-1 group"
46+
>
47+
<span className="text-[11px] text-muted-foreground group-hover:text-foreground transition-colors">{label}</span>
48+
<span className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
49+
checked ? 'bg-primary' : 'bg-muted-foreground/25'
50+
}`}>
51+
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform ${
52+
checked ? 'translate-x-3.5' : 'translate-x-0.5'
53+
}`} />
54+
</span>
55+
</button>
56+
);
57+
}
58+
59+
export const DiffOptionsPopover: React.FC = () => {
60+
const diffStyle = useConfigValue('diffStyle');
61+
const diffOverflow = useConfigValue('diffOverflow');
62+
const diffIndicators = useConfigValue('diffIndicators');
63+
const diffLineDiffType = useConfigValue('diffLineDiffType');
64+
const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
65+
const diffShowBackground = useConfigValue('diffShowBackground');
66+
const diffHideWhitespace = useConfigValue('diffHideWhitespace');
67+
68+
return (
69+
<Popover.Root>
70+
<Popover.Trigger asChild>
71+
<button
72+
className="text-xs text-muted-foreground hover:text-foreground rounded hover:bg-muted transition-colors flex items-center px-1.5 py-1"
73+
title="Diff display options"
74+
>
75+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
76+
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
77+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
78+
</svg>
79+
</button>
80+
</Popover.Trigger>
81+
<Popover.Portal>
82+
<Popover.Content
83+
align="end"
84+
sideOffset={6}
85+
className="z-50 w-64 bg-popover text-popover-foreground border border-border rounded-lg shadow-lg overflow-hidden origin-[var(--radix-popover-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
86+
>
87+
<div className="p-2.5 space-y-2">
88+
<div className="space-y-1.5">
89+
<div>
90+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1">Layout</div>
91+
<div className="grid grid-cols-2 gap-1.5">
92+
<div>
93+
<CompactSegmented options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
94+
</div>
95+
<div>
96+
<CompactSegmented options={OVERFLOW_OPTIONS} value={diffOverflow} onChange={(v) => configStore.set('diffOverflow', v)} />
97+
</div>
98+
</div>
99+
</div>
100+
<div>
101+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1">Indicators</div>
102+
<CompactSegmented options={INDICATOR_OPTIONS} value={diffIndicators} onChange={(v) => configStore.set('diffIndicators', v)} />
103+
</div>
104+
<div>
105+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1">Inline diff</div>
106+
<CompactSegmented options={LINE_DIFF_OPTIONS} value={diffLineDiffType} onChange={(v) => configStore.set('diffLineDiffType', v)} />
107+
</div>
108+
</div>
109+
110+
<div className="border-t border-border/50" />
111+
112+
<div>
113+
<CompactToggle checked={diffShowLineNumbers} onChange={(v) => configStore.set('diffShowLineNumbers', v)} label="Line numbers" />
114+
<CompactToggle checked={diffShowBackground} onChange={(v) => configStore.set('diffShowBackground', v)} label="Diff background" />
115+
<CompactToggle checked={diffHideWhitespace} onChange={(v) => configStore.set('diffHideWhitespace', v)} label="Hide whitespace" />
116+
</div>
117+
</div>
118+
</Popover.Content>
119+
</Popover.Portal>
120+
</Popover.Root>
121+
);
122+
};

packages/review-editor/components/DiffViewer.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
22
import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react';
3-
import { getSingularPatch, processFile } from '@pierre/diffs';
3+
import { getSingularPatch, processFile, parseDiffFromFile } from '@pierre/diffs';
44
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types';
55
import type { DiffTokenEventBaseProps } from '@pierre/diffs';
66
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
@@ -127,6 +127,7 @@ interface DiffViewerProps {
127127
lineDiffType?: 'word-alt' | 'word' | 'char' | 'none';
128128
disableLineNumbers?: boolean;
129129
disableBackground?: boolean;
130+
hideWhitespace?: boolean;
130131
fontFamily?: string;
131132
fontSize?: string;
132133
annotations: CodeAnnotation[];
@@ -172,6 +173,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
172173
lineDiffType,
173174
disableLineNumbers,
174175
disableBackground,
176+
hideWhitespace,
175177
fontFamily,
176178
fontSize,
177179
annotations,
@@ -292,19 +294,27 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
292294

293295
// Re-parse the patch with full file contents so hunk indices are computed
294296
// against the complete file (isPartial: false), enabling expansion.
297+
// When hideWhitespace is on, recompute the diff from file contents to
298+
// exclude whitespace-only changes (like GitHub's ?w=1).
295299
const augmentedDiff = useMemo(() => {
296300
if (!fileContents || fileContents.forPath !== filePath || (fileContents.old == null && fileContents.new == null)) return fileDiff;
297301
try {
302+
if (hideWhitespace && fileContents.old != null && fileContents.new != null) {
303+
return parseDiffFromFile(
304+
{ name: oldPath || filePath, contents: fileContents.old },
305+
{ name: filePath, contents: fileContents.new },
306+
{ ignoreWhitespace: true },
307+
);
308+
}
298309
const result = processFile(patch, {
299310
oldFile: fileContents.old != null ? { name: oldPath || filePath, contents: fileContents.old } : undefined,
300311
newFile: fileContents.new != null ? { name: filePath, contents: fileContents.new } : undefined,
301312
});
302313
return result || fileDiff;
303314
} catch {
304-
// Fall back to partial diff if file contents don't match hunks
305315
return fileDiff;
306316
}
307-
}, [patch, filePath, oldPath, fileContents, fileDiff]);
317+
}, [patch, filePath, oldPath, fileContents, fileDiff, hideWhitespace]);
308318

309319
const previousScrollFilePathRef = useRef(filePath);
310320
useLayoutEffect(() => {

packages/review-editor/components/FileHeader.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useEffect, useRef, useState } from 'react';
2+
import { DiffOptionsPopover } from './DiffOptionsPopover';
23

34
interface FileHeaderProps {
45
filePath: string;
@@ -196,6 +197,7 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
196197
</>
197198
)}
198199
</button>
200+
<DiffOptionsPopover />
199201
</div>
200202
</div>
201203
);

0 commit comments

Comments
 (0)