Skip to content

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/commit

Source

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

Commit props
PropTypeDefaultDescription
filesCommitFile[]= —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.