Commit
An expandable changed-file summary with folder groups, line counts, and a diff action.
Usage
import { Commit } from "@/components/ai/commit"
export function Example() {
return <Commit files={[
{ path: "src/components/ai/permission-grant.tsx", additions: 1, deletions: 1 },
{ path: "DESIGN.md", additions: 1, deletions: 0 },
]} onOpen={() => console.log("Open diff")} />
}
Install
pnpm dlx shadcn@latest add @pastaui/commitnpx shadcn@latest add @pastaui/commityarn dlx shadcn@latest add @pastaui/commitbunx --bun shadcn@latest add @pastaui/commitSource
import { AltArrowDownIcon as ChevronDownIcon } from '@solar-icons/react/linear/alt-arrow-down'
import { FolderIcon } from '@solar-icons/react/linear/folder'
import { DocumentIcon as FileIcon } from '@solar-icons/react/linear/document'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from 'cn'
interface CommitFile {
path: string
additions: number
deletions: number
}
interface CommitProps extends React.ComponentProps<'article'> {
files: CommitFile[]
onOpen?: () => void
}
function ChangeCounts({ additions, deletions }: { additions: number; deletions: number }) {
return (
<span className="ml-auto flex shrink-0 gap-4 font-mono text-xs tabular-nums">
<span
className="text-emerald-600 dark:text-emerald-400"
aria-label={`${additions} additions`}
>
+{additions}
</span>
<span className="text-red-600 dark:text-red-400" aria-label={`${deletions} deletions`}>
−{deletions}
</span>
</span>
)
}
function CommitFileRow({ file, nested = false }: { file: CommitFile; nested?: boolean }) {
const name = file.path.split('/').pop() ?? file.path
const isReact = /\.[jt]sx$/.test(name)
const isMarkdown = /\.mdx?$/.test(name)
const Icon = isReact ? ReactIcon : isMarkdown ? MarkdownIcon : FileIcon
return (
<li className={cn('flex min-h-8 items-center gap-2.5 px-4', nested ? 'pl-12' : 'pl-9')}>
<Icon
aria-hidden="true"
className={cn(
'size-4 shrink-0',
isReact && 'text-sky-500 dark:text-sky-400',
isMarkdown && 'text-green-600 dark:text-green-400',
)}
/>
<span className="min-w-0 truncate font-mono text-sm text-foreground/85" title={file.path}>
{name}
</span>
<ChangeCounts additions={file.additions} deletions={file.deletions} />
</li>
)
}
function Commit({ className, files, onOpen, ...props }: CommitProps) {
const [expanded, setExpanded] = React.useState(true)
const [collapsedFolders, setCollapsedFolders] = React.useState<Set<string>>(new Set())
const contentId = React.useId()
const groups = new Map<string, CommitFile[]>()
for (const file of files) {
const folder = file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : ''
const group = groups.get(folder) ?? []
group.push(file)
groups.set(folder, group)
}
const additions = files.reduce((total, file) => total + file.additions, 0)
const deletions = files.reduce((total, file) => total + file.deletions, 0)
function toggleFolder(folder: string) {
setCollapsedFolders((current) => {
const next = new Set(current)
if (next.has(folder)) next.delete(folder)
else next.add(folder)
return next
})
}
return (
<article
data-slot="commit"
className={cn(
'overflow-hidden rounded-[16px] bg-card font-sans text-sm font-[450] tracking-[-0.05px] text-muted-foreground shadow-card',
className,
)}
{...props}
>
<header className="flex flex-wrap items-center gap-x-3 gap-y-2 bg-muted/25 px-4 py-3">
<h3 className="card-heading text-foreground">
{files.length} changed {files.length === 1 ? 'file' : 'files'}
</h3>
<span className="flex gap-1.5 text-sm tabular-nums">
<span className="text-emerald-600 dark:text-emerald-400">+{additions}</span>
<span className="text-red-600 dark:text-red-400">−{deletions}</span>
</span>
<div className="ml-auto flex items-center gap-2">
<Button
aria-label={expanded ? 'Collapse changed files' : 'Expand changed files'}
aria-expanded={expanded}
aria-controls={contentId}
onClick={() => setExpanded(!expanded)}
size="icon-sm"
variant="ghost"
>
<ChevronDownIcon
className={cn(
'size-4 transition-[rotate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none',
!expanded && '-rotate-90',
)}
/>
</Button>
<Button
className="h-7 rounded-full px-2 text-sm font-[450] tracking-[-0.05px] text-muted-foreground hover:text-foreground"
disabled={!onOpen}
onClick={onOpen}
size="sm"
variant="ghost"
>
<FileIcon data-icon="inline-start" /> Open diff
</Button>
</div>
</header>
<div
id={contentId}
hidden={!expanded}
className="py-2 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] starting:opacity-0"
>
{files.length === 0 && <p className="px-4 py-2 text-xs leading-5">No changed files.</p>}
{Array.from(groups, ([folder, groupFiles]) =>
folder ? (
<div key={folder}>
<button
type="button"
aria-expanded={!collapsedFolders.has(folder)}
onClick={() => toggleFolder(folder)}
className="flex min-h-8 w-full items-center gap-2.5 px-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<ChevronDownIcon
aria-hidden="true"
className={cn(
'size-3.5 shrink-0 transition-[rotate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none',
collapsedFolders.has(folder) && '-rotate-90',
)}
/>
<FolderIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="min-w-0 truncate font-mono" title={folder}>
{folder}
</span>
<ChangeCounts
additions={groupFiles.reduce((total, file) => total + file.additions, 0)}
deletions={groupFiles.reduce((total, file) => total + file.deletions, 0)}
/>
</button>
{!collapsedFolders.has(folder) && (
<ul className="transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] starting:opacity-0">
{groupFiles.map((file) => (
<CommitFileRow key={file.path} file={file} nested />
))}
</ul>
)}
</div>
) : (
<ul key="root">
{groupFiles.map((file) => (
<CommitFileRow key={file.path} file={file} />
))}
</ul>
),
)}
</div>
</article>
)
}
export { Commit }
export type { CommitFile, CommitProps }
function ReactIcon(props: React.ComponentProps<'svg'>) {
return (
<svg fill="currentColor" height="1em" viewBox="0 0 24 24" width="1em" {...props}>
<path d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236a2.236 2.236 0 0 1-2.236-2.236a2.236 2.236 0 0 1 2.235-2.236a2.236 2.236 0 0 1 2.236 2.236m2.648-10.69c-1.346 0-3.107.96-4.888 2.622c-1.78-1.653-3.542-2.602-4.887-2.602c-.41 0-.783.093-1.106.278c-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03c-.704 3.113-.39 5.588.988 6.38c.32.187.69.275 1.102.275c1.345 0 3.107-.96 4.888-2.624c1.78 1.654 3.542 2.603 4.887 2.603c.41 0 .783-.09 1.106-.275c1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032c.704-3.11.39-5.587-.988-6.38a2.17 2.17 0 0 0-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127c.666.382.955 1.835.73 3.704c-.054.46-.142.945-.25 1.44a23.5 23.5 0 0 0-3.107-.534A24 24 0 0 0 12.769 4.7c1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28c-.686.72-1.37 1.537-2.02 2.442a23 23 0 0 0-3.113.538a15 15 0 0 1-.254-1.42c-.23-1.868.054-3.32.714-3.707c.19-.09.4-.127.563-.132zm4.882 3.05q.684.704 1.36 1.564c-.44-.02-.89-.034-1.345-.034q-.691-.001-1.36.034c.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093q.61.874 1.183 1.86q.557.961 1.018 1.946c-.308.655-.646 1.31-1.013 1.95c-.38.66-.773 1.288-1.18 1.87a25.6 25.6 0 0 1-4.412.005a27 27 0 0 1-1.183-1.86q-.557-.961-1.018-1.946a25 25 0 0 1 1.013-1.954c.38-.66.773-1.286 1.18-1.868A25 25 0 0 1 12 8.098zm-3.635.254c-.24.377-.48.763-.704 1.16q-.336.585-.635 1.174c-.265-.656-.49-1.31-.676-1.947c.64-.15 1.315-.283 2.015-.386zm7.26 0q1.044.153 2.006.387c-.18.632-.405 1.282-.66 1.933a26 26 0 0 0-1.345-2.32zm3.063.675q.727.226 1.375.498c1.732.74 2.852 1.708 2.852 2.476c-.005.768-1.125 1.74-2.857 2.475c-.42.18-.88.342-1.355.493a24 24 0 0 0-1.1-2.98c.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98a23 23 0 0 0-1.086 2.964c-.484-.15-.944-.318-1.37-.5c-1.732-.737-2.852-1.706-2.852-2.474s1.12-1.742 2.852-2.476c.42-.18.88-.342 1.356-.494m11.678 4.28c.265.657.49 1.312.676 1.948c-.64.157-1.316.29-2.016.39a26 26 0 0 0 1.341-2.338zm-9.945.02c.2.392.41.783.64 1.175q.345.586.705 1.143a22 22 0 0 1-2.006-.386c.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423c.23 1.868-.054 3.32-.714 3.708c-.147.09-.338.128-.563.128c-1.012 0-2.514-.807-4.11-2.28c.686-.72 1.37-1.536 2.02-2.44c1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532c.66.905 1.345 1.727 2.035 2.446c-1.595 1.483-3.092 2.295-4.11 2.295a1.2 1.2 0 0 1-.553-.132c-.666-.38-.955-1.834-.73-3.703c.054-.46.142-.944.25-1.438zm4.56.64q.661.032 1.345.034q.691.001 1.36-.034c-.44.572-.895 1.095-1.345 1.565q-.684-.706-1.36-1.565" />
</svg>
)
}
function MarkdownIcon(props: React.ComponentProps<'svg'>) {
return (
<svg fill="currentColor" height="1em" viewBox="0 0 24 24" width="1em" {...props}>
<path d="M22.27 19.385H1.73A1.73 1.73 0 0 1 0 17.655V6.345a1.73 1.73 0 0 1 1.73-1.73h20.54A1.73 1.73 0 0 1 24 6.345v11.308a1.73 1.73 0 0 1-1.73 1.731zM5.769 15.923v-4.5l2.308 2.885l2.307-2.885v4.5h2.308V8.078h-2.308l-2.307 2.885l-2.308-2.885H3.46v7.847zM21.232 12h-2.309V8.077h-2.307V12h-2.308l3.461 4.039z" />
</svg>
)
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| files | CommitFile[] | = — | Changed files with path, additions, and deletions. Folder groups and totals are computed automatically. |
| onOpen | () => void | = — | Opens your diff view. The action is disabled when omitted. |