Commit
Commit summary: message, hash, author, timestamp and changed files.
import { Commit, CommitAuthor, CommitAuthorAvatar, CommitContent, CommitCopyButton, CommitFile, CommitFileAdditions, CommitFileChanges, CommitFileDeletions, CommitFileInfo, CommitFilePath, CommitFileStatus, CommitFiles, CommitFilesToggle, CommitHash, CommitHeader, CommitInfo, CommitMessage, CommitMetadata, CommitSeparator, CommitTimestamp,} from "@/components/ai/commit";
const files = [ { path: "lib/keeper.ts", status: "modified", add: 12, del: 3 }, { path: "lib/drift.ts", status: "added", add: 28, del: 0 }, { path: "tests/keeper.test.ts", status: "renamed", add: 4, del: 4 }, { path: "lib/old-keeper.ts", status: "deleted", add: 0, del: 41 },] as const;
const date = new Date("2026-03-14T09:30:00Z");
export default function Demo() { return ( <Commit className="max-w-lg" defaultOpen> <CommitHeader> <CommitAuthor> <CommitAuthorAvatar initials="DL" /> </CommitAuthor> <CommitInfo> <CommitMessage> feat(keeper): skip rebalance under 2% drift </CommitMessage> <CommitMetadata> <span>Dewi Lestari</span> <CommitSeparator /> <CommitTimestamp date={date} /> </CommitMetadata> </CommitInfo> <CommitHash> a3f9c21 <CommitCopyButton hash="a3f9c21" /> </CommitHash> </CommitHeader> <CommitFilesToggle count={files.length} /> <CommitContent> <CommitFiles> {files.map((f) => ( <CommitFile key={f.path}> <CommitFileInfo> <CommitFileStatus status={f.status} /> <CommitFilePath>{f.path}</CommitFilePath> </CommitFileInfo> <CommitFileChanges> <CommitFileAdditions count={f.add} /> <CommitFileDeletions count={f.del} /> </CommitFileChanges> </CommitFile> ))} </CommitFiles> </CommitContent> </Commit> );}<script setup lang="ts">import { Commit, CommitAuthor, CommitAuthorAvatar, CommitContent, CommitCopyButton, CommitFile, CommitFileAdditions, CommitFileChanges, CommitFileDeletions, CommitFileInfo, CommitFilePath, CommitFiles, CommitFilesToggle, CommitFileStatus, CommitHash, CommitHeader, CommitInfo, CommitMessage, CommitMetadata, CommitSeparator, CommitTimestamp,} from "@/components/ai/commit";
const files = [ { path: "lib/keeper.ts", status: "modified", add: 12, del: 3 }, { path: "lib/drift.ts", status: "added", add: 28, del: 0 }, { path: "tests/keeper.test.ts", status: "renamed", add: 4, del: 4 }, { path: "lib/old-keeper.ts", status: "deleted", add: 0, del: 41 },] as const;
const date = new Date("2026-03-14T09:30:00Z");</script>
<template> <Commit class="max-w-lg" default-open> <CommitHeader> <CommitAuthor> <CommitAuthorAvatar initials="DL" /> </CommitAuthor> <CommitInfo> <CommitMessage>feat(keeper): skip rebalance under 2% drift</CommitMessage> <CommitMetadata> <span>Dewi Lestari</span> <CommitSeparator /> <CommitTimestamp :date="date" /> </CommitMetadata> </CommitInfo> <CommitHash> a3f9c21 <CommitCopyButton hash="a3f9c21" /> </CommitHash> </CommitHeader> <CommitFilesToggle :count="files.length" /> <CommitContent> <CommitFiles> <CommitFile v-for="f in files" :key="f.path"> <CommitFileInfo> <CommitFileStatus :status="f.status" /> <CommitFilePath>{{ f.path }}</CommitFilePath> </CommitFileInfo> <CommitFileChanges> <CommitFileAdditions :count="f.add" /> <CommitFileDeletions :count="f.del" /> </CommitFileChanges> </CommitFile> </CommitFiles> </CommitContent> </Commit></template><script lang="ts"> import { Commit, CommitAuthor, CommitAuthorAvatar, CommitContent, CommitCopyButton, CommitFile, CommitFileAdditions, CommitFileChanges, CommitFileDeletions, CommitFileInfo, CommitFilePath, CommitFiles, CommitFilesToggle, CommitFileStatus, CommitHash, CommitHeader, CommitInfo, CommitMessage, CommitMetadata, CommitSeparator, CommitTimestamp, } from "$lib/components/ai/commit";
const files = [ { path: "lib/keeper.ts", status: "modified", add: 12, del: 3 }, { path: "lib/drift.ts", status: "added", add: 28, del: 0 }, { path: "tests/keeper.test.ts", status: "renamed", add: 4, del: 4 }, { path: "lib/old-keeper.ts", status: "deleted", add: 0, del: 41 }, ] as const;
const date = new Date("2026-03-14T09:30:00Z");</script>
<Commit class="max-w-lg" open> <CommitHeader> <CommitAuthor> <CommitAuthorAvatar initials="DL" /> </CommitAuthor> <CommitInfo> <CommitMessage>feat(keeper): skip rebalance under 2% drift</CommitMessage> <CommitMetadata> <span>Dewi Lestari</span> <CommitSeparator /> <CommitTimestamp {date} /> </CommitMetadata> </CommitInfo> <CommitHash> a3f9c21 <CommitCopyButton hash="a3f9c21" /> </CommitHash> </CommitHeader> <CommitFilesToggle count={files.length} /> <CommitContent> <CommitFiles> {#each files as f (f.path)} <CommitFile> <CommitFileInfo> <CommitFileStatus status={f.status} /> <CommitFilePath>{f.path}</CommitFilePath> </CommitFileInfo> <CommitFileChanges> <CommitFileAdditions count={f.add} /> <CommitFileDeletions count={f.del} /> </CommitFileChanges> </CommitFile> {/each} </CommitFiles> </CommitContent></Commit>Built on avatar, badge, button and collapsible.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-commitpnpm dlx shadcn@latest add @edmi-ui/ai-commityarn dlx shadcn@latest add @edmi-ui/ai-commitbunx shadcn@latest add @edmi-ui/ai-commitPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
avatar,badge,button,collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-commitpnpm dlx shadcn-vue@latest add @edmi-ui/ai-commityarn dlx shadcn-vue@latest add @edmi-ui/ai-commitbunx shadcn-vue@latest add @edmi-ui/ai-commitPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vue @vueuse/core reka-uiTerminal window pnpm add @lucide/vue @vueuse/core reka-uiTerminal window yarn add @lucide/vue @vueuse/core reka-uiTerminal window bun add @lucide/vue @vueuse/core reka-uiAdd the Edmi components this one builds on:
avatar,badge,button,collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
avatar,badge,button,collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Commit, CommitAuthor, CommitAuthorAvatar, CommitContent, CommitCopyButton, CommitFiles, CommitFilesToggle, CommitHash, CommitHeader, CommitInfo, CommitMessage, CommitMetadata, CommitSeparator, CommitTimestamp,} from "@/components/ai/commit";
<Commit className="max-w-lg" defaultOpen> <CommitHeader> <CommitAuthor> <CommitAuthorAvatar initials="DL" /> </CommitAuthor> <CommitInfo> <CommitMessage> feat(keeper): skip rebalance under 2% drift </CommitMessage> <CommitMetadata> <span>Dewi Lestari</span> <CommitSeparator /> <CommitTimestamp /> </CommitMetadata> </CommitInfo> <CommitHash> a3f9c21 <CommitCopyButton hash="a3f9c21" /> </CommitHash> </CommitHeader> <CommitFilesToggle /> <CommitContent> <CommitFiles> </CommitFiles> </CommitContent></Commit><script setup lang="ts">import { Commit, CommitAuthor, CommitAuthorAvatar, CommitCopyButton, CommitFilesToggle, CommitHash, CommitHeader, CommitInfo, CommitMessage, CommitMetadata, CommitSeparator, CommitTimestamp,} from "@/components/ai/commit";</script>
<template> <Commit class="max-w-lg" default-open> <CommitHeader> <CommitAuthor> <CommitAuthorAvatar initials="DL" /> </CommitAuthor> <CommitInfo> <CommitMessage>feat(keeper): skip rebalance under 2% drift</CommitMessage> <CommitMetadata> <span>Dewi Lestari</span> <CommitSeparator /> <CommitTimestamp /> </CommitMetadata> </CommitInfo> <CommitHash> a3f9c21 <CommitCopyButton hash="a3f9c21" /> </CommitHash> </CommitHeader> <CommitFilesToggle /> <!-- … --> </Commit></template><script lang="ts"> import { Commit, CommitAuthor, CommitAuthorAvatar, CommitCopyButton, CommitFilesToggle, CommitHash, CommitHeader, CommitInfo, CommitMessage, CommitMetadata, CommitSeparator, CommitTimestamp, } from "$lib/components/ai/commit";</script>
<Commit class="max-w-lg" open> <CommitHeader> <CommitAuthor> <CommitAuthorAvatar initials="DL" /> </CommitAuthor> <CommitInfo> <CommitMessage>feat(keeper): skip rebalance under 2% drift</CommitMessage> <CommitMetadata> <span>Dewi Lestari</span> <CommitSeparator /> <CommitTimestamp /> </CommitMetadata> </CommitInfo> <CommitHash> a3f9c21 <CommitCopyButton hash="a3f9c21" /> </CommitHash> </CommitHeader> <CommitFilesToggle /> <!-- … --></Commit>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import type { ComponentProps, HTMLAttributes } from "react";import { useCallback, useEffect, useRef, useState } from "react";import { CheckIcon, CopyIcon, CaretDownIcon, FileIcon } from "@phosphor-icons/react";import { Avatar, AvatarFallback } from "@/registry/edmi/ui/avatar";import { Badge } from "@/registry/edmi/ui/badge";import { Button } from "@/registry/edmi/ui/button";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/registry/edmi/ui/collapsible";
export type CommitProps = ComponentProps<typeof Collapsible>;
/** A git commit: author, message, time, short hash and the changed files. */export const Commit = ({ className, children, ...props }: CommitProps) => ( <Collapsible data-slot="ai-commit" className={cn( "overflow-hidden rounded-xl border border-border bg-card text-card-foreground", className, )} {...props} > {children} </Collapsible>);
export type CommitHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CommitHeader = ({ className, children, ...props}: CommitHeaderProps) => ( <div data-slot="ai-commit-header" className={cn("flex items-start gap-2.5 px-4 py-3.5", className)} {...props} > {children} </div>);
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
/** Hash chip: mono, outline. Put `CommitCopyButton` inside it for the board's chip with a copy icon. */export const CommitHash = ({ className, children, ...props}: CommitHashProps) => ( <span className={cn( "inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md border border-input px-2 font-mono text-xs text-foreground", className, )} {...props} > {children} </span>);
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
export const CommitMessage = ({ className, children, ...props}: CommitMessageProps) => ( <span className={cn("text-[13.5px] font-semibold", className)} {...props}> {children} </span>);
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
export const CommitMetadata = ({ className, children, ...props}: CommitMetadataProps) => ( <div className={cn( "flex items-center gap-1.5 text-xs text-muted-foreground", className, )} {...props} > {children} </div>);
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>;
export const CommitSeparator = ({ className, children, ...props}: CommitSeparatorProps) => ( <span className={className} {...props}> {children ?? "·"} </span>);
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>;
export const CommitInfo = ({ className, children, ...props}: CommitInfoProps) => ( <div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)} {...props} > {children} </div>);
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>;
export const CommitAuthor = ({ className, children, ...props}: CommitAuthorProps) => ( <div className={cn("flex items-center", className)} {...props}> {children} </div>);
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & { initials: string;};
export const CommitAuthorAvatar = ({ initials, className, ...props}: CommitAuthorAvatarProps) => ( <Avatar className={cn("size-[30px]", className)} {...props}> <AvatarFallback className="text-[11px]">{initials}</AvatarFallback> </Avatar>);
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & { date: Date;};
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { numeric: "auto",});
const formatRelativeDate = (date: Date) => { const seconds = Math.round((date.getTime() - Date.now()) / 1000); const steps: [Intl.RelativeTimeFormatUnit, number][] = [ ["day", 86_400], ["hour", 3600], ["minute", 60], ]; for (const [unit, size] of steps) { if (Math.abs(seconds) >= size) { return relativeTimeFormat.format(Math.round(seconds / size), unit); } } return relativeTimeFormat.format(0, "second");};
export const CommitTimestamp = ({ date, className, children, ...props}: CommitTimestampProps) => { const [formatted, setFormatted] = useState("");
useEffect(() => { setFormatted(formatRelativeDate(date)); }, [date]);
return ( <time className={cn("text-xs", className)} dateTime={date.toISOString()} {...props} > {children ?? formatted} </time> );};
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>;
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();const handleActionsKeyDown = (e: React.KeyboardEvent) => e.stopPropagation();
export const CommitActions = ({ className, children, ...props}: CommitActionsProps) => ( // biome-ignore lint/a11y/useSemanticElements: a plain group that only stops propagation <div className={cn("flex items-center gap-1", className)} onClick={handleActionsClick} onKeyDown={handleActionsKeyDown} role="group" {...props} > {children} </div>);
export type CommitCopyButtonProps = ComponentProps<typeof Button> & { hash: string; onCopy?: () => void; onError?: (error: Error) => void; timeout?: number;};
export const CommitCopyButton = ({ hash, onCopy, onError, timeout = 2000, children, className, ...props}: CommitCopyButtonProps) => { const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef<number>(0);
const copyToClipboard = useCallback(async () => { if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { onError?.(new Error("Clipboard API not available")); return; }
try { if (!isCopied) { await navigator.clipboard.writeText(hash); setIsCopied(true); onCopy?.(); timeoutRef.current = window.setTimeout( () => setIsCopied(false), timeout, ); } } catch (error) { onError?.(error as Error); } }, [hash, onCopy, onError, timeout, isCopied]);
useEffect( () => () => { window.clearTimeout(timeoutRef.current); }, [], );
return ( <Button aria-label="Copy hash" className={cn( "size-4 shrink-0 rounded-sm text-muted-foreground", className, )} onClick={copyToClipboard} size="icon-xs" variant="ghost" {...props} > {children ?? (isCopied ? ( <CheckIcon className="size-3" /> ) : ( <CopyIcon className="size-3" /> ))} </Button> );};
export type CommitFilesToggleProps = ComponentProps< typeof CollapsibleTrigger> & { /** Number of files; renders "N files changed" when no children are given. */ count?: number;};
/** ✦ The "N files changed" row that opens and closes the file list (sits under the header, own top border). */export const CommitFilesToggle = ({ count, className, children, ...props}: CommitFilesToggleProps) => ( <div className="border-t border-border-2 px-4 pt-2 pb-1.5"> <CollapsibleTrigger className={cn( "group/commit-toggle flex w-full cursor-pointer items-center gap-2 py-1 text-left text-[13.5px] text-muted-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", className, )} {...props} > <CaretDownIcon className="size-3.5 transition-transform group-data-[panel-open]/commit-toggle:rotate-180" /> <span> {children ?? `${count ?? 0} file${count === 1 ? "" : "s"} changed`} </span> </CollapsibleTrigger> </div>);
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>;
export const CommitContent = ({ className, children, ...props}: CommitContentProps) => ( <CollapsibleContent className={cn("px-4 pb-3", className)} {...props}> {children} </CollapsibleContent>);
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>;
export const CommitFiles = ({ className, children, ...props}: CommitFilesProps) => ( <div className={cn("flex flex-col", className)} {...props}> {children} </div>);
export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
export const CommitFile = ({ className, children, ...props}: CommitFileProps) => ( <div className={cn( "flex items-center justify-between gap-2 py-1 text-[12.5px]", className, )} {...props} > {children} </div>);
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>;
export const CommitFileInfo = ({ className, children, ...props}: CommitFileInfoProps) => ( <div className={cn("flex min-w-0 items-center gap-2", className)} {...props}> {children} </div>);
const fileStatusVariants = { added: "success", deleted: "destructive", modified: "warning", renamed: "info",} as const;
const fileStatusLabels = { added: "A", deleted: "D", modified: "M", renamed: "R",};
export type CommitFileStatusProps = ComponentProps<typeof Badge> & { status: "added" | "modified" | "deleted" | "renamed";};
export const CommitFileStatus = ({ status, className, children, ...props}: CommitFileStatusProps) => ( <Badge className={cn( "h-[18px] w-5 justify-center p-0 font-mono text-[10.5px]", className, )} variant={fileStatusVariants[status]} {...props} > {children ?? fileStatusLabels[status]} </Badge>);
export type CommitFileIconProps = ComponentProps<"svg">;
export const CommitFileIcon = ({ className, ...props}: CommitFileIconProps) => ( <FileIcon className={cn("size-3.5 shrink-0 text-muted-foreground", className)} {...props} />);
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
export const CommitFilePath = ({ className, children, ...props}: CommitFilePathProps) => ( <span className={cn("truncate font-mono", className)} {...props}> {children} </span>);
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
export const CommitFileChanges = ({ className, children, ...props}: CommitFileChangesProps) => ( <div className={cn("flex shrink-0 items-center gap-2 font-mono", className)} {...props} > {children} </div>);
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & { count: number;};
/** `+12`; always rendered (`+0` stays visible, board AI 05). */export const CommitFileAdditions = ({ count, className, children, ...props}: CommitFileAdditionsProps) => ( <span className={cn("text-success-text", className)} {...props}> {children ?? `+${count}`} </span>);
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & { count: number;};
export const CommitFileDeletions = ({ count, className, children, ...props}: CommitFileDeletionsProps) => ( <span className={cn("text-destructive-text", className)} {...props}> {children ?? `−${count}`} </span>);<script setup lang="ts">import type { CollapsibleRootEmits, CollapsibleRootProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { reactiveOmit } from "@vueuse/core"import { useForwardPropsEmits } from "reka-ui"import { cn } from "@/registry/edmi/lib/utils"import { Collapsible } from "@/registry/edmi/ui/collapsible"
// A git commit: author, message, time, short hash and the changed files.const props = defineProps<CollapsibleRootProps & { class?: HTMLAttributes["class"] }>()const emits = defineEmits<CollapsibleRootEmits>()
const delegatedProps = reactiveOmit(props, "class")const forwarded = useForwardPropsEmits(delegatedProps, emits)</script>
<template> <Collapsible v-bind="forwarded" data-slot="ai-commit" :class="cn('overflow-hidden rounded-xl border border-border bg-card text-card-foreground', props.class)" > <slot /> </Collapsible></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div role="group" :class="cn('flex items-center gap-1', props.class)" @click.stop @keydown.stop > <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div :class="cn('flex items-center', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Avatar, AvatarFallback } from "@/registry/edmi/ui/avatar"
const props = defineProps<{ initials: string class?: HTMLAttributes["class"]}>()</script>
<template> <Avatar :class="cn('size-[30px]', props.class)"> <AvatarFallback class="text-[11px]"> {{ props.initials }} </AvatarFallback> </Avatar></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { CollapsibleContent } from "@/registry/edmi/ui/collapsible"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <CollapsibleContent :class="cn('px-4 pb-3', props.class)"> <slot /> </CollapsibleContent></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { CheckIcon, CopyIcon } from "@lucide/vue"import { onBeforeUnmount, ref } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"
const props = withDefaults(defineProps<{ hash: string timeout?: number class?: HTMLAttributes["class"]}>(), { timeout: 2000,})
const emit = defineEmits<{ (event: "copy"): void (event: "error", error: Error): void}>()
const isCopied = ref(false)let resetTimer: ReturnType<typeof setTimeout> | undefined
async function copyToClipboard() { if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { emit("error", new Error("Clipboard API not available")) return } try { await navigator.clipboard.writeText(props.hash) isCopied.value = true emit("copy") if (resetTimer) { clearTimeout(resetTimer) } resetTimer = setTimeout(() => { isCopied.value = false }, props.timeout) } catch (error) { emit("error", error instanceof Error ? error : new Error("Copy failed")) }}
onBeforeUnmount(() => { if (resetTimer) { clearTimeout(resetTimer) }})</script>
<template> <Button aria-label="Copy hash" :class="cn('size-4 shrink-0 rounded-sm text-muted-foreground', props.class)" size="icon-xs" type="button" variant="ghost" @click="copyToClipboard" > <slot> <CheckIcon v-if="isCopied" class="size-3" /> <CopyIcon v-else class="size-3" /> </slot> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div :class="cn('flex items-center justify-between gap-2 py-1 text-[12.5px]', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
// `+12`; always rendered (`+0` stays visible, board AI 05).const props = defineProps<{ count: number class?: HTMLAttributes["class"]}>()</script>
<template> <span :class="cn('text-success-text', props.class)"> <slot>+{{ props.count }}</slot> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div :class="cn('flex shrink-0 items-center gap-2 font-mono', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ count: number class?: HTMLAttributes["class"]}>()</script>
<template> <span :class="cn('text-destructive-text', props.class)"> <slot>−{{ props.count }}</slot> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { FileIcon } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <FileIcon :class="cn('size-3.5 shrink-0 text-muted-foreground', props.class)" /></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div :class="cn('flex min-w-0 items-center gap-2', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span :class="cn('truncate font-mono', props.class)"> <slot /> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Badge } from "@/registry/edmi/ui/badge"
const props = defineProps<{ status: "added" | "modified" | "deleted" | "renamed" class?: HTMLAttributes["class"]}>()
const variants = { added: "success", deleted: "destructive", modified: "warning", renamed: "info",} as const
const labels = { added: "A", deleted: "D", modified: "M", renamed: "R" }
const variant = computed(() => variants[props.status])</script>
<template> <Badge :variant="variant" :class="cn('h-[18px] w-5 justify-center p-0 font-mono text-[10.5px]', props.class)" > <slot>{{ labels[props.status] }}</slot> </Badge></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div :class="cn('flex flex-col', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { ChevronDownIcon } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"import { CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"
// ✦ The "N files changed" row that opens and closes the file list (sits under the header, own top border).const props = defineProps<{ /** Number of files; renders "N files changed" when the slot is empty. */ count?: number class?: HTMLAttributes["class"]}>()</script>
<template> <div class="border-t border-border-2 px-4 pt-2 pb-1.5"> <CollapsibleTrigger as="button" :class="cn( 'group/commit-toggle flex w-full cursor-pointer items-center gap-2 py-1 text-left text-[13.5px] text-muted-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring', props.class, )" > <ChevronDownIcon class="size-3.5 transition-transform group-data-[state=open]/commit-toggle:rotate-180" /> <span> <slot>{{ props.count ?? 0 }} file{{ props.count === 1 ? "" : "s" }} changed</slot> </span> </CollapsibleTrigger> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
// Hash chip: mono, outline. Put `CommitCopyButton` inside it for the board's chip with a copy icon.const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span :class="cn('inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md border border-input px-2 font-mono text-xs text-foreground', props.class)"> <slot /> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div data-slot="ai-commit-header" :class="cn('flex items-start gap-2.5 px-4 py-3.5', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div :class="cn('flex min-w-0 flex-1 flex-col gap-0.5', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span :class="cn('text-[13.5px] font-semibold', props.class)"> <slot /> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div :class="cn('flex items-center gap-1.5 text-xs text-muted-foreground', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span :class="props.class"> <slot>·</slot> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { onMounted, ref, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ date: Date class?: HTMLAttributes["class"]}>()
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { numeric: "auto" })
function formatRelativeDate(date: Date) { const seconds = Math.round((date.getTime() - Date.now()) / 1000) const steps: [Intl.RelativeTimeFormatUnit, number][] = [ ["day", 86_400], ["hour", 3600], ["minute", 60], ] for (const [unit, size] of steps) { if (Math.abs(seconds) >= size) { return relativeTimeFormat.format(Math.round(seconds / size), unit) } } return relativeTimeFormat.format(0, "second")}
// Formatted after mount so server and client markup match.const formatted = ref("")onMounted(() => { formatted.value = formatRelativeDate(props.date)})watch(() => props.date, (date) => { formatted.value = formatRelativeDate(date)})</script>
<template> <time :class="cn('text-xs', props.class)" :datetime="props.date.toISOString()"> <slot>{{ formatted }}</slot> </time></template>export { default as Commit } from "./Commit.vue";export { default as CommitActions } from "./CommitActions.vue";export { default as CommitAuthor } from "./CommitAuthor.vue";export { default as CommitAuthorAvatar } from "./CommitAuthorAvatar.vue";export { default as CommitContent } from "./CommitContent.vue";export { default as CommitCopyButton } from "./CommitCopyButton.vue";export { default as CommitFile } from "./CommitFile.vue";export { default as CommitFileAdditions } from "./CommitFileAdditions.vue";export { default as CommitFileChanges } from "./CommitFileChanges.vue";export { default as CommitFileDeletions } from "./CommitFileDeletions.vue";export { default as CommitFileIcon } from "./CommitFileIcon.vue";export { default as CommitFileInfo } from "./CommitFileInfo.vue";export { default as CommitFilePath } from "./CommitFilePath.vue";export { default as CommitFileStatus } from "./CommitFileStatus.vue";export { default as CommitFiles } from "./CommitFiles.vue";export { default as CommitFilesToggle } from "./CommitFilesToggle.vue";export { default as CommitHash } from "./CommitHash.vue";export { default as CommitHeader } from "./CommitHeader.vue";export { default as CommitInfo } from "./CommitInfo.vue";export { default as CommitMessage } from "./CommitMessage.vue";export { default as CommitMetadata } from "./CommitMetadata.vue";export { default as CommitSeparator } from "./CommitSeparator.vue";export { default as CommitTimestamp } from "./CommitTimestamp.vue";<script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div role="group" class={cn("flex items-center gap-1", className)} onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} {...restProps}> {@render children?.()}</div><script lang="ts"> import { Avatar, AvatarFallback } from "$lib/registry/ui/avatar/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { initials, class: className, ...restProps }: ComponentProps<typeof Avatar> & { initials: string } = $props();</script>
<Avatar class={cn("size-[30px]", className)} {...restProps}> <AvatarFallback class="text-[11px]">{initials}</AvatarFallback></Avatar><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex items-center", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { CollapsibleContent } from "$lib/registry/ui/collapsible/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { class: className, children, ...restProps }: ComponentProps<typeof CollapsibleContent> = $props();</script>
<CollapsibleContent class={cn("px-4 pb-3", className)} {...restProps}> {@render children?.()}</CollapsibleContent><script lang="ts"> import CheckIcon from "phosphor-svelte/lib/Check"; import CopyIcon from "phosphor-svelte/lib/Copy"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { cn } from "$lib/utils.js"; import { onDestroy } from "svelte";
let { hash, onCopy, onError, timeout = 2000, class: className, children, ...restProps }: Omit<ButtonProps, "onclick" | "href"> & { hash: string; onCopy?: () => void; onError?: (error: Error) => void; timeout?: number; } = $props();
let isCopied = $state(false); let timer: ReturnType<typeof setTimeout> | undefined;
async function copy() { if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { onError?.(new Error("Clipboard API not available")); return; } try { await navigator.clipboard.writeText(hash); isCopied = true; onCopy?.(); clearTimeout(timer); timer = setTimeout(() => (isCopied = false), timeout); } catch (error) { onError?.(error as Error); } }
onDestroy(() => clearTimeout(timer));</script>
<Button aria-label="Copy hash" class={cn("size-4 shrink-0 rounded-sm text-muted-foreground", className)} onclick={copy} size="icon-xs" type="button" variant="ghost" {...restProps}> {#if children} {@render children()} {:else if isCopied} <CheckIcon class="size-3" /> {:else} <CopyIcon class="size-3" /> {/if}</Button><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
// `+12`; always rendered (`+0` stays visible, board AI 05). let { count, class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { count: number; children?: Snippet } = $props();</script>
<span class={cn("text-success-text", className)} {...restProps}> {#if children}{@render children()}{:else}+{count}{/if}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex shrink-0 items-center gap-2 font-mono", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { count, class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { count: number; children?: Snippet } = $props();</script>
<span class={cn("text-destructive-text", className)} {...restProps}> {#if children}{@render children()}{:else}−{count}{/if}</span><script lang="ts"> import FileIcon from "phosphor-svelte/lib/File"; import { cn } from "$lib/utils.js"; import type { SVGAttributes } from "svelte/elements";
let { class: className, ...restProps }: SVGAttributes<SVGSVGElement> = $props();</script>
<FileIcon class={cn("size-3.5 shrink-0 text-muted-foreground", className)} {...(restProps as object)}/><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex min-w-0 items-center gap-2", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { children?: Snippet } = $props();</script>
<span class={cn("truncate font-mono", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { Badge } from "$lib/registry/ui/badge/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { status, class: className, children, ...restProps }: ComponentProps<typeof Badge> & { status: "added" | "modified" | "deleted" | "renamed"; } = $props();
const variants = { added: "success", deleted: "destructive", modified: "warning", renamed: "info", } as const; const labels = { added: "A", deleted: "D", modified: "M", renamed: "R" };</script>
<Badge variant={variants[status]} class={cn("h-[18px] w-5 justify-center p-0 font-mono text-[10.5px]", className)} {...restProps}> {#if children}{@render children()}{:else}{labels[status]}{/if}</Badge><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex items-center justify-between gap-2 py-1 text-[12.5px]", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import CaretDownIcon from "phosphor-svelte/lib/CaretDown"; import { CollapsibleTrigger } from "$lib/registry/ui/collapsible/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
// ✦ The "N files changed" row that opens and closes the file list (sits under the header, own top border). let { count, class: className, children, ...restProps }: ComponentProps<typeof CollapsibleTrigger> & { count?: number } = $props();</script>
<div class="border-t border-border-2 px-4 pt-2 pb-1.5"> <CollapsibleTrigger class={cn( "group/commit-toggle flex w-full cursor-pointer items-center gap-2 py-1 text-left text-[13.5px] text-muted-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", className )} {...restProps} > <CaretDownIcon class="size-3.5 transition-transform group-data-[state=open]/commit-toggle:rotate-180" /> <span> {#if children}{@render children()}{:else}{count ?? 0} file{count === 1 ? "" : "s"} changed{/if} </span> </CollapsibleTrigger></div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex flex-col", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { children?: Snippet } = $props();</script>
<span class={cn("inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md border border-input px-2 font-mono text-xs text-foreground", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div data-slot="ai-commit-header" class={cn("flex items-start gap-2.5 px-4 py-3.5", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { children?: Snippet } = $props();</script>
<span class={cn("text-[13.5px] font-semibold", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex items-center gap-1.5 text-xs text-muted-foreground", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { children?: Snippet } = $props();</script>
<span {...restProps}> {#if children}{@render children()}{:else}·{/if}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLTimeAttributes } from "svelte/elements";
let { date, class: className, children, ...restProps }: HTMLTimeAttributes & { date: Date; children?: Snippet } = $props();
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
function formatRelativeDate(value: Date) { const seconds = Math.round((value.getTime() - Date.now()) / 1000); const steps: [Intl.RelativeTimeFormatUnit, number][] = [ ["day", 86_400], ["hour", 3600], ["minute", 60], ]; for (const [unit, size] of steps) { if (Math.abs(seconds) >= size) { return relativeTimeFormat.format(Math.round(seconds / size), unit); } } return relativeTimeFormat.format(0, "second"); }
// Formatted after mount so server and client markup match. let formatted = $state(""); $effect(() => { formatted = formatRelativeDate(date); });</script>
<time class={cn("text-xs", className)} datetime={date.toISOString()} {...restProps}> {#if children}{@render children()}{:else}{formatted}{/if}</time><script lang="ts"> import { Collapsible } from "$lib/registry/ui/collapsible/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
// A git commit: author, message, time, short hash and the changed files. let { open = $bindable(false), class: className, children, ...restProps }: ComponentProps<typeof Collapsible> = $props();</script>
<Collapsible bind:open data-slot="ai-commit" class={cn("overflow-hidden rounded-xl border border-border bg-card text-card-foreground", className)} {...restProps}> {@render children?.()}</Collapsible>import Root from "./commit.svelte";import Actions from "./commit-actions.svelte";import Author from "./commit-author.svelte";import AuthorAvatar from "./commit-author-avatar.svelte";import Content from "./commit-content.svelte";import CopyButton from "./commit-copy-button.svelte";import File from "./commit-file.svelte";import FileAdditions from "./commit-file-additions.svelte";import FileChanges from "./commit-file-changes.svelte";import FileDeletions from "./commit-file-deletions.svelte";import FileIcon from "./commit-file-icon.svelte";import FileInfo from "./commit-file-info.svelte";import FilePath from "./commit-file-path.svelte";import FileStatus from "./commit-file-status.svelte";import Files from "./commit-files.svelte";import FilesToggle from "./commit-files-toggle.svelte";import Hash from "./commit-hash.svelte";import Header from "./commit-header.svelte";import Info from "./commit-info.svelte";import Message from "./commit-message.svelte";import Metadata from "./commit-metadata.svelte";import Separator from "./commit-separator.svelte";import Timestamp from "./commit-timestamp.svelte";
export { Actions as CommitActions, Author as CommitAuthor, AuthorAvatar as CommitAuthorAvatar, Content as CommitContent, CopyButton as CommitCopyButton, File as CommitFile, FileAdditions as CommitFileAdditions, FileChanges as CommitFileChanges, FileDeletions as CommitFileDeletions, FileIcon as CommitFileIcon, FileInfo as CommitFileInfo, FilePath as CommitFilePath, FileStatus as CommitFileStatus, Files as CommitFiles, FilesToggle as CommitFilesToggle, Hash as CommitHash, Header as CommitHeader, Info as CommitInfo, Message as CommitMessage, Metadata as CommitMetadata, Root as Commit, Separator as CommitSeparator, Timestamp as CommitTimestamp,};| Part | Prop | Type | Default |
|---|---|---|---|
| Commit | Collapsible props (defaultOpen) |
||
| CommitAuthorAvatar | initials |
string |
|
| CommitTimestamp | date |
Date |
|
| CommitCopyButton | hash, onCopy, onError, timeout |
2000 |
|
| CommitFilesToggle | count |
number |
|
| CommitFileStatus | status |
added | modified | deleted | renamed |
|
| CommitFileAdditions / CommitFileDeletions | count |
number |
✦ Edmi additions
Section titled “✦ Edmi additions”CommitFilesToggle: the “N files changed” row that opens the file list (the header itself no longer toggles).- Relative timestamps resolve to minutes and hours, not only days.
- Status badges use the soft success, warning, info and destructive variants; additions and deletions always render (
+0,−0).