DiffFileView
Show the diff of one file as unified or split rows with word-level highlights of what changed inside a line. Unchanged runs longer than contextLines collapse and expand on click. The split layout is available from splitMinWidth (720px of width). Binary, deleted, empty and renamed-without-changes files get dedicated states. headerActions adds controls to the header, for example a Viewed checkbox.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Unified and split
Narrow
Deleted, renamed and binary files
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
diff/DiffFileView/DiffFileView.tsx
import React, { Fragment, memo, useMemo, useState } from 'react';
import {
ActionIcon,
Box,
Button,
CopyButton,
Divider,
EmptyState,
Group,
Paper,
SegmentedControl,
Table,
Text,
Tooltip,
VisuallyHidden,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import {
IconBinary,
IconCheck,
IconCopy,
IconFileAlert,
IconFileMinus,
IconFilePlus,
IconFileDiff,
IconSelector,
} from '@tabler/icons-react';
import { cx } from '../../utils/cx';
import { DiffStats, FileIcon, FileStatusBadge, getFileStatusLabel } from '../DiffMeta';
import {
buildDiffRows,
collapseUnchanged,
toSplitRows,
type CollapsedItem,
type DiffRow,
type SplitDiffRow,
} from '../diff-rows';
import { formatBytes } from '../../utils/shell-output';
import { overlaySegments } from '../../utils/highlight-overlay';
import {
getTokenStyle,
useHighlightedLines,
type HighlightedLines,
type HighlightToken,
type SyntaxHighlighter,
} from '../../utils/highlighter';
import {
computeFileStats,
countLines,
getFileSize,
splitPath,
stripTrailingNewline,
} from '../file-tree';
import { resolveDiffLabels } from '../labels';
import type { DiffLabels, FileChange, WordSegment } from '../types';
import classes from './DiffFileView.module.css';
export type DiffViewMode = 'unified' | 'split';
export interface DiffFileViewProps {
/** File change to show */
change: FileChange;
/** Layout, controlled */
mode?: DiffViewMode;
/** Initial layout when uncontrolled, `unified` by default */
defaultMode?: DiffViewMode;
/** Called when the user switches the layout */
onModeChange?: (mode: DiffViewMode) => void;
/** Unchanged lines kept around each change, `3` by default */
contextLines?: number;
/** Component width in px from which the split layout is available, `720` by default */
splitMinWidth?: number;
/** Colors the code with this highlighter; the language is `change.language` or the file extension */
highlighter?: SyntaxHighlighter;
/** Files larger than this many bytes show a stub with a Show anyway button, `512000` by default */
maxBytes?: number;
/** Content rendered at the end of the header, for example a Viewed checkbox */
headerActions?: React.ReactNode;
/** Overrides of the default English labels */
labels?: Partial<DiffLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
const MARKERS = { add: '+', remove: '−', context: ' ' } as const;
function WordChange({
type,
children,
style,
}: {
type: 'added' | 'removed';
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<Box
component={type === 'added' ? 'ins' : 'del'}
className={classes.word}
data-type={type}
style={style}
>
{children}
</Box>
);
}
function LineChangeLabel({ type, labels }: { type?: DiffRow['type']; labels: DiffLabels }) {
if (type === 'add') {
return <VisuallyHidden>{labels.lineAdded}</VisuallyHidden>;
}
return type === 'remove' ? <VisuallyHidden>{labels.lineRemoved}</VisuallyHidden> : null;
}
interface RevealState {
path: string;
gaps: ReadonlySet<number>;
deleted: boolean;
large: boolean;
untracked: boolean;
}
const NO_GAPS: ReadonlySet<number> = new Set();
function hiddenReveal(path: string): RevealState {
return { path, gaps: NO_GAPS, deleted: false, large: false, untracked: false };
}
function Segments({ segments, tokens }: { segments: WordSegment[]; tokens?: HighlightToken[] }) {
if (tokens) {
return (
<>
{overlaySegments(
tokens,
segments.map((segment) => ({ text: segment.text, mark: segment.type }))
).map((piece, index) => {
const tokenStyle = piece.token ? getTokenStyle(piece.token) : undefined;
return piece.mark === 'equal' ? (
<span key={index} style={tokenStyle}>
{piece.text}
</span>
) : (
<WordChange key={index} type={piece.mark} style={tokenStyle}>
{piece.text}
</WordChange>
);
})}
</>
);
}
return (
<>
{segments.map((segment, index) =>
segment.type === 'equal' ? (
<Fragment key={index}>{segment.text}</Fragment>
) : (
<WordChange key={index} type={segment.type}>
{segment.text}
</WordChange>
)
)}
</>
);
}
interface CodeLines {
old: HighlightedLines | null;
new: HighlightedLines | null;
}
function lineTokens(row: DiffRow | undefined, code: CodeLines): HighlightToken[] | undefined {
if (!row) {
return undefined;
}
if (row.type === 'remove') {
return row.oldNumber ? code.old?.[row.oldNumber - 1] : undefined;
}
return row.newNumber ? code.new?.[row.newNumber - 1] : undefined;
}
function GapRow({
count,
colSpan,
onExpand,
labels,
}: {
count: number;
colSpan: number;
onExpand: () => void;
labels: DiffLabels;
}) {
return (
<Table.Tr className={classes.gap}>
<Table.Td colSpan={colSpan}>
<Button
variant="subtle"
color="gray"
size="compact-xs"
fullWidth
leftSection={<IconSelector size={12} />}
onClick={onExpand}
>
{labels.showUnchanged(count)}
</Button>
</Table.Td>
</Table.Tr>
);
}
function UnifiedRow({ row, code, labels }: { row: DiffRow; code: CodeLines; labels: DiffLabels }) {
return (
<Table.Tr data-type={row.type}>
<Table.Td className={classes.number}>{row.oldNumber}</Table.Td>
<Table.Td className={classes.number}>{row.newNumber}</Table.Td>
<Table.Td className={classes.marker}>
<span aria-hidden>{MARKERS[row.type]}</span>
<LineChangeLabel type={row.type} labels={labels} />
</Table.Td>
<Table.Td className={classes.code}>
<Segments segments={row.segments} tokens={lineTokens(row, code)} />
</Table.Td>
</Table.Tr>
);
}
function SplitRow({
row,
code,
labels,
}: {
row: SplitDiffRow;
code: CodeLines;
labels: DiffLabels;
}) {
const { left, right } = row;
return (
<Table.Tr>
<Table.Td className={classes.number} data-type={left?.type ?? 'empty'}>
{left?.oldNumber}
<LineChangeLabel type={left?.type} labels={labels} />
</Table.Td>
<Table.Td className={cx(classes.code, classes.splitLeft)} data-type={left?.type ?? 'empty'}>
{left && <Segments segments={left.segments} tokens={lineTokens(left, code)} />}
</Table.Td>
<Table.Td className={classes.number} data-type={right?.type ?? 'empty'}>
{right?.newNumber}
<LineChangeLabel type={right?.type} labels={labels} />
</Table.Td>
<Table.Td className={classes.code} data-type={right?.type ?? 'empty'}>
{right && <Segments segments={right.segments} tokens={lineTokens(right, code)} />}
</Table.Td>
</Table.Tr>
);
}
/** Diff of one file: unified or split rows with word highlights, collapsed unchanged runs and stubs for binary, deleted and empty files */
export const DiffFileView = memo(function DiffFileView({
change,
mode: modeProp,
defaultMode = 'unified',
onModeChange,
contextLines = 3,
splitMinWidth = 720,
maxBytes = 512_000,
highlighter,
headerActions,
labels: labelsProp,
className,
style,
}: DiffFileViewProps) {
const labels = useMemo(() => resolveDiffLabels(labelsProp), [labelsProp]);
const { ref, width } = useElementSize();
const [innerMode, setInnerMode] = useState(defaultMode);
const [revealState, setRevealState] = useState(() => hiddenReveal(change.path));
const reveal = revealState.path === change.path ? revealState : hiddenReveal(change.path);
const {
gaps: expandedGaps,
deleted: showDeleted,
large: showLarge,
untracked: showUntracked,
} = reveal;
const updateReveal = (patch: Partial<Omit<RevealState, 'path'>>) =>
setRevealState({ ...reveal, ...patch });
const canSplit = width >= splitMinWidth;
const measuring = width === 0 && (modeProp ?? innerMode) === 'split';
const mode = canSplit ? (modeProp ?? innerMode) : 'unified';
const size = useMemo(() => getFileSize(change), [change]);
const hideLarge = !change.binary && size > maxBytes && !showLarge;
const stats = useMemo(
() =>
hideLarge
? { additions: change.additions ?? 0, deletions: change.deletions ?? 0 }
: computeFileStats(change),
[change, hideLarge]
);
const { directory, name } = splitPath(change.path);
const unchangedRename =
change.status === 'renamed' && (change.oldContent ?? '') === (change.newContent ?? '');
const hideDeleted = change.status === 'deleted' && !showDeleted;
const hideUntracked = Boolean(change.untracked) && !showUntracked;
const showsRows =
!change.binary && !hideLarge && !hideUntracked && !unchangedRename && !hideDeleted;
const rows = useMemo(
() => (showsRows ? buildDiffRows(change.oldContent ?? '', change.newContent ?? '') : []),
[showsRows, change.oldContent, change.newContent]
);
const language = change.language ?? change.path.split('.').pop();
const { lines: oldHighlighted } = useHighlightedLines(
stripTrailingNewline(showsRows ? (change.oldContent ?? '') : ''),
language,
showsRows ? highlighter : undefined
);
const { lines: newHighlighted } = useHighlightedLines(
stripTrailingNewline(showsRows ? (change.newContent ?? '') : ''),
language,
showsRows ? highlighter : undefined
);
const code = useMemo<CodeLines>(
() => ({ old: oldHighlighted, new: newHighlighted }),
[oldHighlighted, newHighlighted]
);
const splitRows = useMemo(() => (mode === 'split' ? toSplitRows(rows) : []), [mode, rows]);
const collapsible = change.status === 'modified' || change.status === 'renamed';
const unifiedItems = useMemo<CollapsedItem<DiffRow>[]>(
() =>
collapsible
? collapseUnchanged(rows, (row) => row.type === 'context', contextLines, expandedGaps)
: rows.map((row, index) => ({ kind: 'row', row, index })),
[collapsible, rows, contextLines, expandedGaps]
);
const splitItems = useMemo<CollapsedItem<SplitDiffRow>[]>(
() =>
collapsible
? collapseUnchanged(
splitRows,
(row) => row.left?.type === 'context',
contextLines,
expandedGaps
)
: splitRows.map((row, index) => ({ kind: 'row', row, index })),
[collapsible, splitRows, contextLines, expandedGaps]
);
const digits = String(
Math.max(countLines(change.oldContent), countLines(change.newContent), 1)
).length;
const expandGap = (start: number) => updateReveal({ gaps: new Set(expandedGaps).add(start) });
const changeMode = (value: string) => {
updateReveal({ gaps: NO_GAPS });
setInnerMode(value as DiffViewMode);
onModeChange?.(value as DiffViewMode);
};
let body: React.ReactNode;
if (change.binary) {
body = (
<EmptyState
size="sm"
py="xl"
icon={<IconBinary />}
title={labels.binaryFile}
description={labels.binaryFileDescription}
/>
);
} else if (hideLarge) {
body = (
<EmptyState
size="sm"
py="xl"
icon={<IconFileAlert />}
title={labels.fileTooLarge}
description={labels.fileTooLargeDescription(formatBytes(size), formatBytes(maxBytes))}
>
<EmptyState.Actions>
<Button size="xs" variant="default" onClick={() => updateReveal({ large: true })}>
{labels.showAnyway}
</Button>
</EmptyState.Actions>
</EmptyState>
);
} else if (hideUntracked) {
body = (
<EmptyState
size="sm"
py="xl"
icon={<IconFilePlus />}
title={labels.untrackedFile}
description={labels.untrackedFileDescription(countLines(change.newContent))}
>
{change.newContent && (
<EmptyState.Actions>
<Button size="xs" variant="default" onClick={() => updateReveal({ untracked: true })}>
{labels.showContent}
</Button>
</EmptyState.Actions>
)}
</EmptyState>
);
} else if (unchangedRename) {
body = (
<EmptyState
size="sm"
py="xl"
icon={<IconFileDiff />}
title={labels.renamedWithoutChanges}
description={`${change.previousPath ?? ''} → ${change.path}`}
/>
);
} else if (hideDeleted) {
const lines = stats.deletions || countLines(change.oldContent);
body = (
<EmptyState
size="sm"
py="xl"
icon={<IconFileMinus />}
title={labels.deletedFile}
description={labels.deletedFileDescription(lines)}
>
{change.oldContent && (
<EmptyState.Actions>
<Button size="xs" variant="default" onClick={() => updateReveal({ deleted: true })}>
{labels.showContent}
</Button>
</EmptyState.Actions>
)}
</EmptyState>
);
} else if (rows.length === 0) {
body = <EmptyState size="sm" py="xl" icon={<IconFileDiff />} title={labels.emptyFile} />;
} else {
body = (
<Table
layout="fixed"
withRowBorders={false}
horizontalSpacing={0}
verticalSpacing={0}
className={cx(classes.table, classes.measured)}
data-mode={mode}
data-measuring={measuring || undefined}
style={{ '--ae-diff-digits': digits } as React.CSSProperties}
>
<colgroup>
{mode === 'split' ? (
<>
<col className={classes.numberCol} />
<col />
<col className={classes.numberCol} />
<col />
</>
) : (
<>
<col className={classes.numberCol} />
<col className={classes.numberCol} />
<col className={classes.markerCol} />
<col />
</>
)}
</colgroup>
<Table.Tbody>
{mode === 'split'
? splitItems.map((item) =>
item.kind === 'gap' ? (
<GapRow
key={`gap-${item.start}`}
count={item.count}
colSpan={4}
labels={labels}
onExpand={() => expandGap(item.start)}
/>
) : (
<SplitRow key={item.index} row={item.row} code={code} labels={labels} />
)
)
: unifiedItems.map((item) =>
item.kind === 'gap' ? (
<GapRow
key={`gap-${item.start}`}
count={item.count}
colSpan={4}
labels={labels}
onExpand={() => expandGap(item.start)}
/>
) : (
<UnifiedRow key={item.index} row={item.row} code={code} labels={labels} />
)
)}
</Table.Tbody>
</Table>
);
}
return (
<Paper
ref={ref}
withBorder
radius="md"
className={cx(classes.root, className)}
style={style}
data-mode={mode}
>
<Group gap="xs" px="sm" py={6} justify="space-between" wrap="wrap" className={classes.header}>
<Group gap={6} wrap="nowrap" miw={0} flex="1 1 16rem">
<FileIcon path={change.path} />
<FileStatusBadge
status={change.status}
untracked={change.untracked}
label={getFileStatusLabel(change.status, labels, change.untracked)}
/>
<Text size="sm" truncate="start" miw={0} className={classes.path} title={change.path}>
{change.previousPath && change.previousPath !== change.path && (
<Text span inherit c="dimmed">
{change.previousPath} →{' '}
</Text>
)}
<Text span inherit c="dimmed">
{directory}
</Text>
<Text span inherit fw={500}>
{name}
</Text>
</Text>
<CopyButton value={change.path}>
{({ copied, copy }) => (
<Tooltip label={copied ? labels.pathCopied : labels.copyPath} withArrow>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={copied ? labels.pathCopied : labels.copyPath}
onClick={copy}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Group gap="xs" wrap="wrap" ml="auto" justify="flex-end">
{!change.binary && <DiffStats additions={stats.additions} deletions={stats.deletions} />}
{canSplit && showsRows && (
<SegmentedControl
size="xs"
value={mode}
onChange={changeMode}
data={[
{ value: 'unified', label: labels.unified },
{ value: 'split', label: labels.split },
]}
/>
)}
{headerActions}
</Group>
</Group>
<Divider />
<Box className={classes.body}>{body}</Box>
</Paper>
);
});
DiffFileView.displayName = 'DiffFileView';
API reference
Prop
Type
Required
change
FileChange
Yes
File change to show
mode
DiffViewMode
No
Layout, controlled
defaultMode
DiffViewMode
No
Initial layout when uncontrolled, `unified` by default
onModeChange
(mode: DiffViewMode) => void
No
Called when the user switches the layout
contextLines
number
No
Unchanged lines kept around each change, `3` by default
splitMinWidth
number
No
Component width in px from which the split layout is available, `720` by default
highlighter
SyntaxHighlighter
No
Colors the code with this highlighter; the language is `change.language` or the file extension
maxBytes
number
No
Files larger than this many bytes show a stub with a Show anyway button, `512000` by default
headerActions
React.ReactNode
No
Content rendered at the end of the header, for example a Viewed checkbox
labels
Partial<DiffLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element