"use client"; import { useEffect, useMemo, type ComponentProps } from "react"; import Tree from "react-d3-tree"; export type CommitTreeItem = { id: string; parent_commit_id: string | null; restored_from_commit_id: string | null; commit_no: number; kind: string; created_by: string; created_at: string; title: string | null; }; type CommitTreeNode = { commit: CommitTreeItem; children: CommitTreeNode[]; }; type CommitTreeDatum = { name: string; commit: CommitTreeItem; isHead: boolean; detail: string; restoredFromLabel: string | null; children?: CommitTreeDatum[]; }; type Props = { open: boolean; commits: CommitTreeItem[]; headCommitId: string | null; onClose: () => void; }; type TreeRenderNode = NonNullable["renderCustomNodeElement"]>; export default function CommitTreePopup({ open, commits, headCommitId, onClose, }: Props) { const { roots, commitById } = useMemo(() => buildCommitTree(commits), [commits]); const treeData = useMemo( () => roots.map((node) => toTreeDatum(node, commitById, headCommitId)), [roots, commitById, headCommitId] ); useEffect(() => { if (!open) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { onClose(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [open, onClose]); if (!open) return null; return (
event.stopPropagation()} style={{ width: "min(1120px, calc(100vw - 48px))", maxHeight: "min(720px, calc(100vh - 48px))", overflow: "hidden", border: "1px solid #334155", borderRadius: "8px", background: "#0f172a", color: "#e2e8f0", boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)", display: "flex", flexDirection: "column", }} >
Commit tree
{commits.length} commit{commits.length === 1 ? "" : "s"}
{treeData.length === 0 ? (
Chưa có commit.
) : (
"commit-tree-link"} />
)}
); } const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) { const datum = nodeDatum as unknown as CommitTreeDatum; const commit = datum.commit; const isHead = datum.isHead; return (
#{commit.commit_no} {isHead ? ( HEAD ) : null}
{formatCommitTitle(commit)}
{datum.detail}
{datum.restoredFromLabel ? (
{datum.restoredFromLabel}
) : null}
); }; function buildCommitTree(commits: CommitTreeItem[]) { const commitById = new Map(); const nodeById = new Map(); for (const commit of commits) { commitById.set(commit.id, commit); nodeById.set(commit.id, { commit, children: [] }); } const roots: CommitTreeNode[] = []; for (const node of nodeById.values()) { const parentId = getDisplayParentCommitId(node.commit); const parent = parentId ? nodeById.get(parentId) : null; if (parent) { parent.children.push(node); } else { roots.push(node); } } const sortNodes = (nodes: CommitTreeNode[]) => { nodes.sort((a, b) => a.commit.commit_no - b.commit.commit_no); for (const node of nodes) { sortNodes(node.children); } }; sortNodes(roots); return { roots, commitById }; } function toTreeDatum( node: CommitTreeNode, commitById: Map, headCommitId: string | null ): CommitTreeDatum { const commit = node.commit; const restoredFromCommit = commit.restored_from_commit_id ? commitById.get(commit.restored_from_commit_id) || null : null; const children = node.children.map((child) => toTreeDatum(child, commitById, headCommitId)); return { name: formatCommitTitle(commit), commit, isHead: headCommitId === commit.id, detail: `${commit.kind} by ${commit.created_by} - ${formatDateTime(commit.created_at)}`, restoredFromLabel: restoredFromCommit ? `restored from #${restoredFromCommit.commit_no} ${formatCommitTitle(restoredFromCommit)}` : null, children: children.length ? children : undefined, }; } function getDisplayParentCommitId(commit: CommitTreeItem): string | null { if (commit.kind === "restore" && commit.restored_from_commit_id) { return commit.restored_from_commit_id; } return commit.parent_commit_id; } function formatCommitTitle(commit: CommitTreeItem): string { return commit.title?.trim() || `Commit #${commit.commit_no}`; } function formatDateTime(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(); }