DiffFileList
List changed files with their status (added, modified, deleted, renamed), added and removed line counts, a viewed check and the accept or reject decision. Switch between a flat list and a folder tree, search by path and filter by status. Handles loading and error states.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
List
Folder tree
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/DiffFileList/DiffFileList.tsx
import React, { memo, useMemo, useRef, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
EmptyState,
Group,
SegmentedControl,
Select,
Skeleton,
Stack,
Text,
TextInput,
Tooltip,
Tree,
useTree,
VisuallyHidden,
type RenderTreeNodePayload,
type TreeNodeData,
} from '@mantine/core';
import {
IconAlertCircle,
IconBinaryTree2,
IconCheck,
IconChevronRight,
IconFileDiff,
IconFolder,
IconList,
IconSearch,
} from '@tabler/icons-react';
import { useOverlayAutofocus } from '../../hooks/use-overlay-autofocus';
import { CompactSearch } from '../../primitives/CompactSearch/CompactSearch';
import { EntityList } from '../../primitives/EntityList/EntityList';
import { EntityListItem } from '../../primitives/EntityList/EntityListItem';
import { cx } from '../../utils/cx';
import { DiffStats, FileIcon, FileStatusBadge, getFileStatusLabel } from '../DiffMeta';
import {
buildFileTree,
computeFileStats,
countChangesByStatus,
filterChanges,
splitPath,
type FileTreeNode,
} from '../file-tree';
import { resolveDiffLabels } from '../labels';
import type { DiffLabels, FileChange, FileChangeStatus, FileDecision, FileStats } from '../types';
import classes from './DiffFileList.module.css';
export type DiffFileListView = 'list' | 'tree';
export interface DiffFileListProps {
/** Changed files */
changes: FileChange[];
/** Path of the selected file */
selectedPath?: string | null;
/** Called when a file is clicked or chosen with Enter */
onSelect?: (change: FileChange) => void;
/** Paths marked as viewed, shown with a check */
viewedPaths?: string[];
/** Accept or reject decision per path */
decisions?: Record<string, FileDecision>;
/** Layout, controlled */
view?: DiffFileListView;
/** Initial layout when uncontrolled, `list` by default */
defaultView?: DiffFileListView;
/** Called when the user switches between list and tree */
onViewChange?: (view: DiffFileListView) => void;
/** One toolbar row: search behind an icon, ghost status filter and view toggles */
compact?: boolean;
/** Marks the search field, or the search button when compact, with `data-autofocus` for an enclosing Drawer or Modal */
searchAutofocus?: boolean;
/** Shows skeleton rows */
loading?: boolean;
/** Error message shown instead of the files */
error?: React.ReactNode;
/** Called by the retry button of the error alert */
onRetry?: () => void;
/** 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 STATUS_ORDER: FileChangeStatus[] = ['modified', 'added', 'deleted', 'renamed'];
function toTreeData(nodes: FileTreeNode[]): TreeNodeData[] {
return nodes.map((node) => ({
value: node.type === 'folder' ? `folder:${node.path}` : node.path,
label: node.name,
children: node.type === 'folder' ? toTreeData(node.children) : undefined,
nodeProps: { node },
}));
}
function collectFolderValues(nodes: FileTreeNode[]): string[] {
return nodes.flatMap((node) =>
node.type === 'folder' ? [`folder:${node.path}`, ...collectFolderValues(node.children)] : []
);
}
/** Changed files as a list or a folder tree with status, line counts, search and a status filter */
export const DiffFileList = memo(function DiffFileList({
changes,
selectedPath,
onSelect,
viewedPaths,
decisions,
view: viewProp,
defaultView = 'list',
onViewChange,
compact = false,
searchAutofocus = false,
loading,
error,
onRetry,
labels: labelsProp,
className,
style,
}: DiffFileListProps) {
const labels = useMemo(() => resolveDiffLabels(labelsProp), [labelsProp]);
const [innerView, setInnerView] = useState(defaultView);
const view = viewProp ?? innerView;
const [query, setQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useOverlayAutofocus(rootRef, searchAutofocus);
const [selectedStatus, setStatus] = useState<FileChangeStatus | 'all'>('all');
const statsByPath = useMemo(
() => new Map(changes.map((change) => [change.path, computeFileStats(change)])),
[changes]
);
const summary = useMemo(() => {
let additions = 0;
let deletions = 0;
for (const stats of statsByPath.values()) {
additions += stats.additions;
deletions += stats.deletions;
}
return { files: changes.length, additions, deletions };
}, [statsByPath, changes.length]);
const statusCounts = useMemo(() => countChangesByStatus(changes), [changes]);
const statusOptions = STATUS_ORDER.filter((item) => statusCounts[item] > 0);
const status =
selectedStatus !== 'all' && statusOptions.length > 1 && statusOptions.includes(selectedStatus)
? selectedStatus
: 'all';
const visible = useMemo(() => filterChanges(changes, query, status), [changes, query, status]);
const viewed = useMemo(() => new Set(viewedPaths), [viewedPaths]);
const changeView = (next: string) => {
setInnerView(next as DiffFileListView);
onViewChange?.(next as DiffFileListView);
};
const renderBadges = (change: FileChange) => {
const decision = decisions?.[change.path];
return (
<>
{viewed.has(change.path) && (
<IconCheck size={14} className={classes.viewed} aria-label={labels.viewed} />
)}
{decision && (
<Badge
size="xs"
variant="light"
color={decision === 'accepted' ? 'green' : 'red'}
tt="none"
>
{decision === 'accepted' ? labels.accepted : labels.rejected}
</Badge>
)}
</>
);
};
let body: React.ReactNode;
if (loading) {
body = (
<Stack gap="xs" aria-busy="true" px="xs">
{Array.from({ length: 5 }, (_, index) => (
<Group key={index} gap="sm" wrap="nowrap">
<Skeleton height={16} width={16} radius="sm" />
<Skeleton height={10} width={`${50 + ((index * 13) % 35)}%`} radius="sm" />
</Group>
))}
</Stack>
);
} else if (error) {
body = (
<Alert color="red" variant="light" icon={<IconAlertCircle size={16} />}>
<Stack gap="xs" align="flex-start">
<Text size="sm">{error}</Text>
{onRetry && (
<Button size="xs" variant="light" color="red" onClick={onRetry}>
{labels.retry}
</Button>
)}
</Stack>
</Alert>
);
} else if (changes.length === 0) {
body = <EmptyState size="sm" py="lg" icon={<IconFileDiff />} title={labels.noFiles} />;
} else if (visible.length === 0) {
body = (
<Text size="sm" c="dimmed" ta="center" py="lg">
{labels.noMatchingFiles}
</Text>
);
} else if (view === 'tree') {
body = (
<FileTree
changes={visible}
selectedPath={selectedPath}
onSelect={onSelect}
labels={labels}
renderBadges={renderBadges}
statsByPath={statsByPath}
/>
);
} else {
body = (
<EntityList<FileChange>
items={visible}
getId={(change) => change.path}
selectedId={selectedPath}
onSelect={onSelect}
ariaLabel={labels.filesChanged(visible.length)}
renderItem={(change, { selected }) => {
const { directory, name } = splitPath(change.path);
const stats = statsByPath.get(change.path) ?? computeFileStats(change);
return (
<EntityListItem
selected={selected}
icon={<FileIcon path={change.path} />}
title={
<Text span inherit td={change.status === 'deleted' ? 'line-through' : undefined}>
{name}
</Text>
}
description={
change.previousPath && change.previousPath !== change.path
? `${change.previousPath} → ${directory || './'}`
: directory || undefined
}
descriptionLines={1}
status={
<FileStatusBadge
status={change.status}
untracked={change.untracked}
label={getFileStatusLabel(change.status, labels, change.untracked)}
/>
}
badges={renderBadges(change)}
meta={
change.binary ? undefined : (
<DiffStats additions={stats.additions} deletions={stats.deletions} />
)
}
/>
);
}}
/>
);
}
const statusSelectData = [
{ value: 'all', label: `${labels.allStatuses} (${changes.length})` },
...statusOptions.map((item) => ({
value: item,
label: `${getFileStatusLabel(item, labels)} (${statusCounts[item]})`,
})),
];
const hasData = changes.length > 0 && !loading && !error;
const closeSearch = () => {
setQuery('');
setSearchOpen(false);
};
if (compact) {
return (
<Stack
ref={rootRef}
gap={4}
tabIndex={searchAutofocus ? -1 : undefined}
data-autofocus={searchAutofocus || undefined}
className={cx(classes.focusTarget, className)}
style={style}
>
<Group gap={4} wrap="nowrap" mih={30}>
<CompactSearch
opened={searchOpen}
onOpen={() => setSearchOpen(true)}
onClose={closeSearch}
value={query}
onChange={setQuery}
labels={{ search: labels.searchFiles, close: labels.closeSearch }}
/>
{!searchOpen && (
<Group gap={6} wrap="nowrap" miw={0} flex={1}>
{hasData && (
<>
<Text size="xs" c="dimmed" truncate>
{labels.filesChanged(summary.files)}
</Text>
<DiffStats additions={summary.additions} deletions={summary.deletions} />
</>
)}
</Group>
)}
{hasData && statusOptions.length > 1 && (
<Select
size="xs"
variant="unstyled"
w={120}
aria-label={labels.statusFilter}
allowDeselect={false}
value={status}
onChange={(value) => value && setStatus(value as FileChangeStatus | 'all')}
data={statusSelectData}
classNames={{ input: classes.ghostSelect }}
/>
)}
<ActionIcon.Group>
{(['list', 'tree'] as const).map((item) => (
<Tooltip
key={item}
label={item === 'list' ? labels.viewList : labels.viewTree}
withArrow
>
<ActionIcon
variant={view === item ? 'light' : 'subtle'}
color="gray"
size="sm"
aria-label={item === 'list' ? labels.viewList : labels.viewTree}
aria-pressed={view === item}
onClick={() => changeView(item)}
>
{item === 'list' ? <IconList size={14} /> : <IconBinaryTree2 size={14} />}
</ActionIcon>
</Tooltip>
))}
</ActionIcon.Group>
</Group>
{body}
</Stack>
);
}
return (
<Stack ref={rootRef} gap="xs" className={className} style={style}>
<Group gap="xs" wrap="nowrap">
<TextInput
flex={1}
miw={0}
size="xs"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder={labels.searchFiles}
aria-label={labels.searchFiles}
data-autofocus={searchAutofocus || undefined}
leftSection={<IconSearch size={14} />}
/>
<SegmentedControl
size="xs"
value={view}
onChange={changeView}
data={[
{
value: 'list',
label: (
<Tooltip label={labels.viewList} withArrow>
<Group gap={0} wrap="nowrap" justify="center">
<IconList size={14} />
<VisuallyHidden>{labels.viewList}</VisuallyHidden>
</Group>
</Tooltip>
),
},
{
value: 'tree',
label: (
<Tooltip label={labels.viewTree} withArrow>
<Group gap={0} wrap="nowrap" justify="center">
<IconBinaryTree2 size={14} />
<VisuallyHidden>{labels.viewTree}</VisuallyHidden>
</Group>
</Tooltip>
),
},
]}
/>
</Group>
{hasData && (
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" miw={0}>
<Text size="xs" c="dimmed" truncate>
{labels.filesChanged(summary.files)}
</Text>
<DiffStats additions={summary.additions} deletions={summary.deletions} />
</Group>
{statusOptions.length > 1 && (
<Select
size="xs"
w={140}
aria-label={labels.statusFilter}
allowDeselect={false}
value={status}
onChange={(value) => value && setStatus(value as FileChangeStatus | 'all')}
data={statusSelectData}
/>
)}
</Group>
)}
{body}
</Stack>
);
});
DiffFileList.displayName = 'DiffFileList';
interface FileTreeProps {
changes: FileChange[];
selectedPath?: string | null;
onSelect?: (change: FileChange) => void;
labels: DiffLabels;
renderBadges: (change: FileChange) => React.ReactNode;
statsByPath: ReadonlyMap<string, FileStats>;
}
function FileTree({
changes,
selectedPath,
onSelect,
labels,
renderBadges,
statsByPath,
}: FileTreeProps) {
const nodes = useMemo(() => buildFileTree(changes), [changes]);
const data = useMemo(() => toTreeData(nodes), [nodes]);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const expandedState = useMemo(() => {
const state: Record<string, boolean> = {};
for (const value of collectFolderValues(nodes)) {
state[value] = !collapsed.has(value);
}
return state;
}, [nodes, collapsed]);
const tree = useTree({
expandedState,
onExpandedStateChange: (state) =>
setCollapsed(
new Set(
Object.entries(state)
.filter(([, expanded]) => !expanded)
.map(([value]) => value)
)
),
selectedState: selectedPath ? [selectedPath] : [],
});
const byPath = useMemo(() => new Map(changes.map((change) => [change.path, change])), [changes]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const change = target.dataset.value ? byPath.get(target.dataset.value) : undefined;
if (event.key === 'Enter' && target.getAttribute('role') === 'treeitem' && change) {
event.preventDefault();
onSelect?.(change);
}
};
const renderNode = ({ node, expanded, selected, elementProps }: RenderTreeNodePayload) => {
const item = node.nodeProps?.node as FileTreeNode;
if (item.type === 'folder') {
return (
<Group
{...elementProps}
gap={6}
wrap="nowrap"
className={cx(elementProps.className, classes.node)}
onClick={(event: React.MouseEvent) => {
elementProps.onClick(event);
tree.toggleExpanded(node.value);
}}
>
<ActionIcon
component="span"
variant="transparent"
color="gray"
size="xs"
className={classes.chevron}
data-expanded={expanded || undefined}
aria-hidden
>
<IconChevronRight size={12} />
</ActionIcon>
<IconFolder size={16} className={classes.folderIcon} aria-hidden />
<Text size="sm" c="dimmed" truncate="end" miw={0}>
{item.name}
</Text>
</Group>
);
}
const stats = statsByPath.get(item.path) ?? computeFileStats(item.change);
return (
<Group
{...elementProps}
gap={6}
wrap="nowrap"
className={cx(elementProps.className, classes.node)}
data-selected={selected || undefined}
onClick={(event: React.MouseEvent) => {
elementProps.onClick(event);
onSelect?.(item.change);
}}
>
<FileIcon path={item.path} />
<Text
size="sm"
truncate="end"
miw={0}
flex={1}
td={item.change.status === 'deleted' ? 'line-through' : undefined}
>
{item.name}
</Text>
{renderBadges(item.change)}
<FileStatusBadge
status={item.change.status}
untracked={item.change.untracked}
label={getFileStatusLabel(item.change.status, labels, item.change.untracked)}
/>
{!item.change.binary && (
<DiffStats additions={stats.additions} deletions={stats.deletions} />
)}
</Group>
);
};
return (
<Tree
data={data}
tree={tree}
expandOnClick={false}
levelOffset="sm"
renderNode={renderNode}
onKeyDown={handleKeyDown}
aria-label={labels.filesChanged(changes.length)}
/>
);
}
Depends on:
API reference
Prop
Type
Required
changes
FileChange[]
Yes
Changed files
selectedPath
string | null
No
Path of the selected file
onSelect
(change: FileChange) => void
No
Called when a file is clicked or chosen with Enter
viewedPaths
string[]
No
Paths marked as viewed, shown with a check
decisions
Record<string, FileDecision>
No
Accept or reject decision per path
view
DiffFileListView
No
Layout, controlled
defaultView
DiffFileListView
No
Initial layout when uncontrolled, `list` by default
onViewChange
(view: DiffFileListView) => void
No
Called when the user switches between list and tree
compact
boolean
No
One toolbar row: search behind an icon, ghost status filter and view toggles
searchAutofocus
boolean
No
Marks the search field, or the search button when compact, with `data-autofocus` for an enclosing Drawer or Modal
loading
boolean
No
Shows skeleton rows
error
React.ReactNode
No
Error message shown instead of the files
onRetry
() => void
No
Called by the retry button of the error alert
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