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>
This commit is contained in:
Michael Ramos
2026-04-29 23:51:17 -07:00
committed by GitHub
parent b017b2992e
commit b4b1d432c8
10 changed files with 347 additions and 10 deletions
+19 -2
View File
@@ -1,9 +1,26 @@
import path from 'path';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';
import tailwindcss from '@tailwindcss/vite';
import pkg from '../../package.json';
import { DEMO_FILE_CONTENTS } from '../../packages/review-editor/demoData';
function demoFileContentPlugin(): Plugin {
return {
name: 'demo-file-content',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/api/file-content')) return next();
const filePath = new URL(req.url, 'http://localhost').searchParams.get('path');
const entry = filePath ? DEMO_FILE_CONTENTS[filePath] : undefined;
if (!entry) return next();
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ oldContent: entry.oldContent, newContent: entry.newContent }));
});
},
};
}
export default defineConfig({
server: {
@@ -13,7 +30,7 @@ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
plugins: [react(), tailwindcss(), viteSingleFile()],
plugins: [demoFileContentPlugin(), react(), tailwindcss(), viteSingleFile()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
+3 -1
View File
@@ -142,6 +142,7 @@ const ReviewApp: React.FC = () => {
const diffLineDiffType = useConfigValue('diffLineDiffType');
const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
const diffShowBackground = useConfigValue('diffShowBackground');
const diffHideWhitespace = useConfigValue('diffHideWhitespace');
const diffFontFamily = useConfigValue('diffFontFamily');
const diffFontSize = useConfigValue('diffFontSize');
@@ -1191,6 +1192,7 @@ 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
@@ -1247,7 +1249,7 @@ const ReviewApp: React.FC = () => {
openTourPanel: handleOpenTour,
}), [
files, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
diffLineDiffType, diffShowLineNumbers, diffShowBackground,
diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffHideWhitespace,
diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope,
allAnnotations, externalAnnotations,
selectedAnnotationId, pendingSelection, handleLineSelection,
@@ -0,0 +1,122 @@
import React from 'react';
import * as Popover from '@radix-ui/react-popover';
import { configStore, useConfigValue } from '@plannotator/ui/config';
import {
DIFF_STYLE_OPTIONS,
OVERFLOW_OPTIONS,
INDICATOR_OPTIONS,
LINE_DIFF_OPTIONS,
} from '@plannotator/ui/components/Settings';
function CompactSegmented<T extends string>({ options, value, onChange }: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center gap-px bg-muted/60 rounded-md p-px">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className={`flex-1 px-2 py-1 text-[11px] rounded-[5px] transition-colors ${
value === opt.value
? 'bg-background text-foreground shadow-sm font-medium'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{opt.label}
</button>
))}
</div>
);
}
function CompactToggle({ checked, onChange, label }: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="w-full flex items-center justify-between py-1 group"
>
<span className="text-[11px] text-muted-foreground group-hover:text-foreground transition-colors">{label}</span>
<span className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
checked ? 'bg-primary' : 'bg-muted-foreground/25'
}`}>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-3.5' : 'translate-x-0.5'
}`} />
</span>
</button>
);
}
export const DiffOptionsPopover: React.FC = () => {
const diffStyle = useConfigValue('diffStyle');
const diffOverflow = useConfigValue('diffOverflow');
const diffIndicators = useConfigValue('diffIndicators');
const diffLineDiffType = useConfigValue('diffLineDiffType');
const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
const diffShowBackground = useConfigValue('diffShowBackground');
const diffHideWhitespace = useConfigValue('diffHideWhitespace');
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className="text-xs text-muted-foreground hover:text-foreground rounded hover:bg-muted transition-colors flex items-center px-1.5 py-1"
title="Diff display options"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="end"
sideOffset={6}
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"
>
<div className="p-2.5 space-y-2">
<div className="space-y-1.5">
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1">Layout</div>
<div className="grid grid-cols-2 gap-1.5">
<div>
<CompactSegmented options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
</div>
<div>
<CompactSegmented options={OVERFLOW_OPTIONS} value={diffOverflow} onChange={(v) => configStore.set('diffOverflow', v)} />
</div>
</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1">Indicators</div>
<CompactSegmented options={INDICATOR_OPTIONS} value={diffIndicators} onChange={(v) => configStore.set('diffIndicators', v)} />
</div>
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-1">Inline diff</div>
<CompactSegmented options={LINE_DIFF_OPTIONS} value={diffLineDiffType} onChange={(v) => configStore.set('diffLineDiffType', v)} />
</div>
</div>
<div className="border-t border-border/50" />
<div>
<CompactToggle checked={diffShowLineNumbers} onChange={(v) => configStore.set('diffShowLineNumbers', v)} label="Line numbers" />
<CompactToggle checked={diffShowBackground} onChange={(v) => configStore.set('diffShowBackground', v)} label="Diff background" />
<CompactToggle checked={diffHideWhitespace} onChange={(v) => configStore.set('diffHideWhitespace', v)} label="Hide whitespace" />
</div>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
@@ -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 } from '@pierre/diffs';
import { getSingularPatch, processFile, parseDiffFromFile } 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,6 +127,7 @@ interface DiffViewerProps {
lineDiffType?: 'word-alt' | 'word' | 'char' | 'none';
disableLineNumbers?: boolean;
disableBackground?: boolean;
hideWhitespace?: boolean;
fontFamily?: string;
fontSize?: string;
annotations: CodeAnnotation[];
@@ -172,6 +173,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
lineDiffType,
disableLineNumbers,
disableBackground,
hideWhitespace,
fontFamily,
fontSize,
annotations,
@@ -292,19 +294,27 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
// 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) {
return parseDiffFromFile(
{ name: oldPath || filePath, contents: fileContents.old },
{ name: filePath, contents: fileContents.new },
{ ignoreWhitespace: true },
);
}
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,
});
return result || fileDiff;
} catch {
// Fall back to partial diff if file contents don't match hunks
return fileDiff;
}
}, [patch, filePath, oldPath, fileContents, fileDiff]);
}, [patch, filePath, oldPath, fileContents, fileDiff, hideWhitespace]);
const previousScrollFilePathRef = useRef(filePath);
useLayoutEffect(() => {
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { DiffOptionsPopover } from './DiffOptionsPopover';
interface FileHeaderProps {
filePath: string;
@@ -196,6 +197,7 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
</>
)}
</button>
<DiffOptionsPopover />
</div>
</div>
);
+156
View File
@@ -2,6 +2,93 @@
* Demo diff data for development mode
*/
/**
* Full old/new file contents for demo files that need file-content serving
* (e.g. for hideWhitespace to work in demo mode). Keyed by file path.
*/
export const DEMO_FILE_CONTENTS: Record<string, { oldContent: string; newContent: string }> = {
'src/config/settings.ts': {
oldContent: `import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export const config = envSchema.parse(process.env);
export function getLogConfig() {
return {
level: config.LOG_LEVEL,
pretty: config.NODE_ENV === 'development',
timestamp: true,
};
}
export function getDatabaseConfig() {
return {
connectionString: config.DATABASE_URL,
pool: {
min: 2,
max: config.NODE_ENV === 'production' ? 20 : 5,
},
ssl: config.NODE_ENV === 'production',
};
}
export function getCacheConfig() {
return {
url: config.REDIS_URL,
ttl: config.NODE_ENV === 'production' ? 3600 : 60,
prefix: 'app:',
};
}`,
newContent: `import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export const config = envSchema.parse(process.env);
export function getLogConfig() {
return {
level: config.LOG_LEVEL,
pretty: config.NODE_ENV === 'development',
timestamp: true,
};
}
export function getDatabaseConfig() {
return {
connectionString: config.DATABASE_URL,
pool: {
min: 2,
max: config.NODE_ENV === 'production' ? 20 : 5,
},
ssl: config.NODE_ENV === 'production',
};
}
export function getCacheConfig() {
return {
url: config.REDIS_URL,
ttl: config.NODE_ENV === 'production' ? 3600 : 60,
prefix: 'app:',
};
}`,
},
};
export const DEMO_DIFF = `diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 1234567..abcdefg 100644
--- a/src/components/Button.tsx
@@ -197,6 +284,75 @@ index 4567890..defghij 100644
+ delete: (id: string) => request(\`/users/\${id}\`, { method: 'DELETE' }),
},
};
diff --git a/src/config/settings.ts b/src/config/settings.ts
index 5678901..efghijk 100644
--- a/src/config/settings.ts
+++ b/src/config/settings.ts
@@ -1,38 +1,38 @@
-import { z } from 'zod';
+import { z } from 'zod';
const envSchema = z.object({
- NODE_ENV: z.enum(['development', 'staging', 'production']),
- PORT: z.coerce.number().default(3000),
- DATABASE_URL: z.string().url(),
- REDIS_URL: z.string().url().optional(),
- JWT_SECRET: z.string().min(32),
- LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
+ NODE_ENV: z.enum(['development', 'staging', 'production']),
+ PORT: z.coerce.number().default(3000),
+ DATABASE_URL: z.string().url(),
+ REDIS_URL: z.string().url().optional(),
+ JWT_SECRET: z.string().min(32),
+ LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export const config = envSchema.parse(process.env);
export function getLogConfig() {
- return {
- level: config.LOG_LEVEL,
- pretty: config.NODE_ENV === 'development',
- timestamp: true,
- };
+ return {
+ level: config.LOG_LEVEL,
+ pretty: config.NODE_ENV === 'development',
+ timestamp: true,
+ };
}
export function getDatabaseConfig() {
- return {
- connectionString: config.DATABASE_URL,
- pool: {
- min: 2,
- max: config.NODE_ENV === 'production' ? 20 : 5,
- },
- ssl: config.NODE_ENV === 'production',
- };
+ return {
+ connectionString: config.DATABASE_URL,
+ pool: {
+ min: 2,
+ max: config.NODE_ENV === 'production' ? 20 : 5,
+ },
+ ssl: config.NODE_ENV === 'production',
+ };
}
export function getCacheConfig() {
- return {
- url: config.REDIS_URL,
- ttl: config.NODE_ENV === 'production' ? 3600 : 60,
- prefix: 'app:',
- };
+ return {
+ url: config.REDIS_URL,
+ ttl: config.NODE_ENV === 'production' ? 3600 : 60,
+ prefix: 'app:',
+ };
}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 0000000..1234567
@@ -26,6 +26,7 @@ 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. */
@@ -78,6 +78,7 @@ export const ReviewDiffPanel: React.FC<IDockviewPanelProps> = (props) => {
lineDiffType={state.lineDiffType}
disableLineNumbers={state.disableLineNumbers}
disableBackground={state.disableBackground}
hideWhitespace={state.hideWhitespace}
fontFamily={state.fontFamily}
fontSize={state.fontSize}
annotations={fileAnnotations}
+15 -4
View File
@@ -102,20 +102,20 @@ const DIFF_FONT_OPTIONS = [
{ value: 'Atkinson Hyperlegible Mono', label: 'Atkinson Hyperlegible' },
];
const DIFF_STYLE_OPTIONS = [
export const DIFF_STYLE_OPTIONS = [
{ value: 'split' as const, label: 'Split' },
{ value: 'unified' as const, label: 'Unified' },
];
const OVERFLOW_OPTIONS = [
export const OVERFLOW_OPTIONS = [
{ value: 'scroll' as const, label: 'Scroll' },
{ value: 'wrap' as const, label: 'Wrap' },
];
const INDICATOR_OPTIONS = [
export const INDICATOR_OPTIONS = [
{ value: 'bars' as const, label: 'Bars' },
{ value: 'classic' as const, label: 'Classic' },
{ value: 'none' as const, label: 'None' },
];
const LINE_DIFF_OPTIONS = [
export const LINE_DIFF_OPTIONS = [
{ value: 'word-alt' as const, label: 'Word-Alt' },
{ value: 'word' as const, label: 'Word' },
{ value: 'char' as const, label: 'Char' },
@@ -228,6 +228,7 @@ const ReviewDisplayTab: React.FC = () => {
const diffLineDiffType = useConfigValue('diffLineDiffType');
const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
const diffShowBackground = useConfigValue('diffShowBackground');
const diffHideWhitespace = useConfigValue('diffHideWhitespace');
const diffFontFamily = useConfigValue('diffFontFamily');
const diffFontSize = useConfigValue('diffFontSize');
@@ -361,6 +362,16 @@ const ReviewDisplayTab: React.FC = () => {
description="Colored backgrounds on added/deleted lines"
/>
<div className="border-t border-border" />
{/* Hide Whitespace */}
<ToggleSwitch
checked={diffHideWhitespace}
onChange={(v) => configStore.set('diffHideWhitespace', v)}
label="Hide Whitespace"
description="Ignore whitespace-only changes in diffs"
/>
</>
);
};
+15
View File
@@ -154,6 +154,21 @@ export const SETTINGS = {
toServer: (v: string) => ({ diffOptions: { fontFamily: v } }),
},
diffHideWhitespace: {
defaultValue: false as boolean,
fromCookie: () => {
const v = storage.getItem('plannotator-diff-hide-whitespace');
return v === 'true' ? true : v === 'false' ? false : undefined;
},
toCookie: (v: boolean) => storage.setItem('plannotator-diff-hide-whitespace', String(v)),
serverKey: 'diffOptions',
fromServer: (sc: Record<string, unknown>) => {
const v = (sc.diffOptions as Record<string, unknown> | undefined)?.hideWhitespace;
return typeof v === 'boolean' ? v : undefined;
},
toServer: (v: boolean) => ({ diffOptions: { hideWhitespace: v } }),
},
diffFontSize: {
defaultValue: '' as string, // empty = theme default
fromCookie: () => storage.getItem('plannotator-diff-font-size') || undefined,