Files
openchamber/packages/ui/src/components/views/forge/ForgeCommitsSection.tsx
T
bot-hermes 92f0eced34 feat(ui): rich forge entity views — commits, files/diff, timeline, checks, metadata chips
- server: new read routes for PR/MR commits, timeline, reviews, commit-statuses across github/gitlab/gitea; enrich PR/issue summaries with labels/assignees/milestone/commentsCount
- ui: forge facade gains getCommits/getTimeline/getChecks; shared ForgeEntityDetailView + section components; mounted into PR/MR views and issue sections
2026-08-16 16:29:24 +00:00

182 lines
7.0 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { GitLogEntry } from '@/lib/api/types';
import type { ForgeCommit } from '@/lib/forge/types';
import { assignLanes } from '@/components/views/git/gitGraph';
import { GitGraphSegment } from '@/components/views/git/GitGraphSegment';
interface ForgeCommitsSectionProps {
commits: ForgeCommit[] | null;
loading?: boolean;
error?: string | null;
}
/**
* Map a normalized forge commit onto the `GitLogEntry`-shaped input
* `assignLanes` consumes. The commit list is authoritative; the synthesized
* fields are only used for lane geometry and display text.
*/
const toLaneEntry = (commit: ForgeCommit): GitLogEntry => ({
hash: commit.sha,
date: commit.committedAt ?? '',
message: commit.summary ?? commit.message,
refs: '',
body: commit.message,
author_name: commit.author?.name ?? commit.author?.login ?? 'Unknown',
author_email: '',
filesChanged: 0,
insertions: 0,
deletions: 0,
parents: commit.parents,
});
const formatCommitDate = (value: string | undefined, timeFormatPreference: TimeFormatPreference): string => {
if (!value) return '';
const ts = Date.parse(value);
if (!Number.isFinite(ts)) return value;
return formatDateTimeForPreference(ts, timeFormatPreference, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
/**
* Commits on a pull request, rendered with the git-graph lane visual language
* (GitGraphSegment over `assignLanes` output). Each row expands to the full
* message and parent shas. Pure presentation.
*/
export const ForgeCommitsSection = React.memo<ForgeCommitsSectionProps>(function ForgeCommitsSection({ commits, loading, error }) {
const { t } = useI18n();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const [expandedShas, setExpandedShas] = useState<Set<string>>(new Set());
const bySha = useMemo(() => new Map((commits ?? []).map((commit) => [commit.sha, commit])), [commits]);
const laned = useMemo(() => assignLanes((commits ?? []).map(toLaneEntry)), [commits]);
const totalLanes = useMemo(
() => laned.reduce((max, item) => Math.max(max, item.lane), -1) + 1,
[laned],
);
const toggle = React.useCallback((sha: string) => {
setExpandedShas((previous) => {
const next = new Set(previous);
if (next.has(sha)) {
next.delete(sha);
} else {
next.add(sha);
}
return next;
});
}, []);
const copyHash = React.useCallback(async (sha: string) => {
const result = await copyTextToClipboard(sha);
if (result.ok) {
toast.success(t('forge.copied'));
}
}, [t]);
if (loading) {
return (
<div className="flex flex-col gap-2" data-testid="forge-commits-loading">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-4/5" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-3 py-2 typography-micro text-[var(--status-error)]">
<Icon name="error-warning" className="size-4 shrink-0" />
{error}
</div>
);
}
if (!commits || commits.length === 0) {
return <p className="py-3 text-center typography-micro text-muted-foreground">{t('forge.commits.empty')}</p>;
}
return (
<ul className="flex flex-col">
{laned.map((item) => {
const commit = bySha.get(item.commit.hash);
if (!commit) return null;
const isExpanded = expandedShas.has(commit.sha);
const author = commit.author?.name ?? commit.author?.login ?? null;
return (
<li key={commit.sha}>
<button
type="button"
onClick={() => toggle(commit.sha)}
className="flex w-full items-start gap-3 px-3 py-2 text-left transition-colors hover:bg-[var(--interactive-hover)]/40"
aria-expanded={isExpanded}
>
<div className="-my-2 shrink-0 self-stretch">
<GitGraphSegment laned={item} totalLanes={totalLanes} isExpanded={isExpanded} />
</div>
<div className="min-w-0 flex-1">
<p className="typography-ui-label font-medium text-foreground line-clamp-1">
{commit.summary ?? commit.message}
</p>
<div className="flex min-w-0 items-center gap-1 typography-meta text-muted-foreground">
{author ? <span className="min-w-0 truncate">{author}</span> : null}
{author && commit.committedAt ? <span className="shrink-0">·</span> : null}
{commit.committedAt ? (
<span className="min-w-0 truncate">{formatCommitDate(commit.committedAt, timeFormatPreference)}</span>
) : null}
<span className="shrink-0">·</span>
<code className="shrink-0 font-mono">{commit.shortSha}</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 shrink-0 px-1"
aria-label={t('gitView.history.copySha')}
onClick={(event) => {
event.stopPropagation();
void copyHash(commit.sha);
}}
>
<Icon name="file-copy" className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.history.copySha')}</TooltipContent>
</Tooltip>
</div>
</div>
</button>
{isExpanded ? (
<div className="border-t border-border/40 px-3 pb-2 pl-8">
<p className="typography-micro text-foreground whitespace-pre-wrap break-words">{commit.message}</p>
{commit.parents.length > 0 ? (
<p className="mt-1 flex flex-wrap items-center gap-1 typography-micro text-muted-foreground">
<span>{t('forge.commits.parents')}:</span>
{commit.parents.map((parent) => (
<code key={parent} className="font-mono">{parent.slice(0, 7)}</code>
))}
</p>
) : null}
</div>
) : null}
</li>
);
})}
</ul>
);
});