Skip to content
edmi-ui

Commit

Commit summary: message, hash, author, timestamp and changed files.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
DL
feat(keeper): skip rebalance under 2% drift
Dewi Lestari·
a3f9c21
Mlib/keeper.ts
+12−3
Alib/drift.ts
+28−0
Rtests/keeper.test.ts
+4−4
Dlib/old-keeper.ts
+0−41
DL
feat(keeper): skip rebalance under 2% drift
Dewi Lestari·
a3f9c21
M
lib/keeper.ts
+12−3
A
lib/drift.ts
+28−0
R
tests/keeper.test.ts
+4−4
D
lib/old-keeper.ts
+0−41
DL
feat(keeper): skip rebalance under 2% drift
Dewi Lestari ·
a3f9c21
M lib/keeper.ts
+12 −3
A lib/drift.ts
+28 −0
R tests/keeper.test.ts
+4 −4
D lib/old-keeper.ts
+0 −41

Built on avatar, badge, button and collapsible.

Terminal window
npx shadcn@latest add @edmi-ui/ai-commit
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-commit
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-commit
Terminal window
bunx shadcn@latest add @edmi-ui/ai-commit

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-commit
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-commit
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-commit
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-commit

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-commit.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/commit.tsx
"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>
);
registry/components/ai/commit/Commit.vue
<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>
registry/components/ai/commit/CommitActions.vue
<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>
registry/components/ai/commit/CommitAuthor.vue
<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>
registry/components/ai/commit/CommitAuthorAvatar.vue
<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>
registry/components/ai/commit/CommitContent.vue
<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>
registry/components/ai/commit/CommitCopyButton.vue
<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>
registry/components/ai/commit/CommitFile.vue
<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>
registry/components/ai/commit/CommitFileAdditions.vue
<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>
registry/components/ai/commit/CommitFileChanges.vue
<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>
registry/components/ai/commit/CommitFileDeletions.vue
<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>
registry/components/ai/commit/CommitFileIcon.vue
<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>
registry/components/ai/commit/CommitFileInfo.vue
<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>
registry/components/ai/commit/CommitFilePath.vue
<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>
registry/components/ai/commit/CommitFileStatus.vue
<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>
registry/components/ai/commit/CommitFiles.vue
<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>
registry/components/ai/commit/CommitFilesToggle.vue
<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>
registry/components/ai/commit/CommitHash.vue
<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>
registry/components/ai/commit/CommitHeader.vue
<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>
registry/components/ai/commit/CommitInfo.vue
<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>
registry/components/ai/commit/CommitMessage.vue
<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>
registry/components/ai/commit/CommitMetadata.vue
<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>
registry/components/ai/commit/CommitSeparator.vue
<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>
registry/components/ai/commit/CommitTimestamp.vue
<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>
registry/components/ai/commit/index.ts
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";
src/lib/registry/ai/commit/commit-actions.svelte
<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>
src/lib/registry/ai/commit/commit-author-avatar.svelte
<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>
src/lib/registry/ai/commit/commit-author.svelte
<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>
src/lib/registry/ai/commit/commit-content.svelte
<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>
src/lib/registry/ai/commit/commit-copy-button.svelte
<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>
src/lib/registry/ai/commit/commit-file-additions.svelte
<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>
src/lib/registry/ai/commit/commit-file-changes.svelte
<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>
src/lib/registry/ai/commit/commit-file-deletions.svelte
<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>
src/lib/registry/ai/commit/commit-file-icon.svelte
<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)}
/>
src/lib/registry/ai/commit/commit-file-info.svelte
<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>
src/lib/registry/ai/commit/commit-file-path.svelte
<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>
src/lib/registry/ai/commit/commit-file-status.svelte
<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>
src/lib/registry/ai/commit/commit-file.svelte
<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>
src/lib/registry/ai/commit/commit-files-toggle.svelte
<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>
src/lib/registry/ai/commit/commit-files.svelte
<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>
src/lib/registry/ai/commit/commit-hash.svelte
<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>
src/lib/registry/ai/commit/commit-header.svelte
<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>
src/lib/registry/ai/commit/commit-info.svelte
<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>
src/lib/registry/ai/commit/commit-message.svelte
<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>
src/lib/registry/ai/commit/commit-metadata.svelte
<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>
src/lib/registry/ai/commit/commit-separator.svelte
<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>
src/lib/registry/ai/commit/commit-timestamp.svelte
<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>
src/lib/registry/ai/commit/commit.svelte
<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>
src/lib/registry/ai/commit/index.ts
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
  • 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).