This update introduces pagination and skipping functionality for the commit listing feature, allowing users to load commits in pages and navigate through them more efficiently. The UI has been adjusted to support loading more commits dynamically, improving the overall user experience when dealing with large repositories. - Added pagination support for commit history - Introduced a loading mechanism for fetching more commits - Updated UI components to reflect changes in commit loading behavior
815 lines
29 KiB
Svelte
815 lines
29 KiB
Svelte
<script lang="ts">
|
|
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
|
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
|
|
|
interface GraphSegment {
|
|
fromCol: number;
|
|
toCol: number;
|
|
color: string;
|
|
branches: string[];
|
|
}
|
|
|
|
interface GraphRow {
|
|
dotCol: number;
|
|
dotColor: string;
|
|
branchLabels: string[];
|
|
top: GraphSegment[];
|
|
bottom: GraphSegment[];
|
|
}
|
|
|
|
interface VisibleCommitEntry {
|
|
commit: GitCommit;
|
|
graphCommit: GitCommit;
|
|
}
|
|
|
|
const GRAPH_COLORS = [
|
|
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
|
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
|
];
|
|
const GRAPH_LANE = 18;
|
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
});
|
|
|
|
interface Props {
|
|
commits: GitCommit[];
|
|
localBranchNames: string[];
|
|
activeBranch: string;
|
|
activeUpstream: string;
|
|
repositoryKey: string;
|
|
hasRepository: boolean;
|
|
isBusy: boolean;
|
|
hasMore: boolean;
|
|
isLoadingMore: boolean;
|
|
loadMoreError: string;
|
|
expandedCommitHashes: Set<string>;
|
|
onLoadMore: () => void | Promise<void>;
|
|
onRestoreCommit: (commit: GitCommit) => void;
|
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
|
onToggleCommitFiles: (hash: string) => void;
|
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
|
onCherryPickCommit: (commit: GitCommit) => void;
|
|
onRevertCommit: (commit: GitCommit) => void;
|
|
}
|
|
|
|
let {
|
|
commits = [],
|
|
localBranchNames = [],
|
|
activeBranch = "",
|
|
activeUpstream = "",
|
|
repositoryKey = "",
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
hasMore = false,
|
|
isLoadingMore = false,
|
|
loadMoreError = "",
|
|
expandedCommitHashes = new Set(),
|
|
onLoadMore = () => {},
|
|
onRestoreCommit = () => {},
|
|
onPreviewCommitFile = () => {},
|
|
onToggleCommitFiles = () => {},
|
|
onCreateBranchFromCommit = () => {},
|
|
onCherryPickCommit = () => {},
|
|
onRevertCommit = () => {},
|
|
}: Props = $props();
|
|
|
|
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
|
let branchDialogOpen = $state(false);
|
|
let userAdjustedBranchFilter = $state(false);
|
|
let lastDefaultFilterKey = $state("");
|
|
let panelElement = $state<HTMLElement | null>(null);
|
|
let contextCommit = $state<GitCommit | null>(null);
|
|
let contextMenuX = $state(0);
|
|
let contextMenuY = $state(0);
|
|
|
|
function observeHistoryEnd(node: HTMLElement) {
|
|
const root = node.closest<HTMLElement>(".history-list");
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
|
|
void onLoadMore();
|
|
}
|
|
}, { root, rootMargin: "240px 0px" });
|
|
observer.observe(node);
|
|
return { destroy: () => observer.disconnect() };
|
|
}
|
|
|
|
function laneColor(col: number): string {
|
|
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
|
}
|
|
|
|
function graphColX(col: number): number {
|
|
return col * GRAPH_LANE + GRAPH_LANE / 2;
|
|
}
|
|
|
|
function graphPath(seg: GraphSegment, fromY: number, toY: number): string {
|
|
const x1 = graphColX(seg.fromCol);
|
|
const x2 = graphColX(seg.toCol);
|
|
if (x1 === x2) return `M ${x1} ${fromY} L ${x2} ${toY}`;
|
|
const midY = (fromY + toY) / 2;
|
|
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
|
|
}
|
|
|
|
function computeGraph(items: GitCommit[], branchMembership = new Map<string, string[]>()): { rows: GraphRow[]; columns: number } {
|
|
const rows: GraphRow[] = [];
|
|
let lanes: (string | null)[] = [];
|
|
let laneBranches: string[][] = [];
|
|
let maxColumns = 1;
|
|
|
|
for (const commit of items) {
|
|
const before = lanes.slice();
|
|
const beforeBranches = laneBranches.map((branches) => branches.slice());
|
|
|
|
let col = before.indexOf(commit.hash);
|
|
if (col === -1) {
|
|
col = before.indexOf(null);
|
|
if (col === -1) col = before.length;
|
|
}
|
|
|
|
const after = before.slice();
|
|
while (after.length <= col) after.push(null);
|
|
const afterBranches = beforeBranches.map((branches) => branches.slice());
|
|
while (afterBranches.length <= col) afterBranches.push([]);
|
|
|
|
for (let k = 0; k < after.length; k++) {
|
|
if (after[k] === commit.hash) {
|
|
after[k] = null;
|
|
afterBranches[k] = [];
|
|
}
|
|
}
|
|
|
|
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
|
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
|
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
|
afterBranches[col] = after[col] ? commitBranches.slice() : [];
|
|
|
|
const fromCommit = new Set<number>([col]);
|
|
for (let p = 1; p < commit.parents.length; p++) {
|
|
let slot = after.indexOf(null);
|
|
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
|
after[slot] = commit.parents[p];
|
|
afterBranches[slot] = [];
|
|
fromCommit.add(slot);
|
|
}
|
|
|
|
const top: GraphSegment[] = [];
|
|
for (let k = 0; k < before.length; k++) {
|
|
const target = before[k];
|
|
if (target == null) continue;
|
|
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k), branches: beforeBranches[k] ?? [] });
|
|
}
|
|
|
|
const bottom: GraphSegment[] = [];
|
|
for (let k = 0; k < after.length; k++) {
|
|
if (after[k] == null) continue;
|
|
bottom.push({
|
|
fromCol: fromCommit.has(k) ? col : k,
|
|
toCol: k,
|
|
color: laneColor(k),
|
|
branches: fromCommit.has(k) ? commitBranches : afterBranches[k] ?? [],
|
|
});
|
|
}
|
|
|
|
rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
|
|
|
|
lanes = after.slice();
|
|
laneBranches = afterBranches.map((branches) => branches.slice());
|
|
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
|
|
lanes.pop();
|
|
laneBranches.pop();
|
|
}
|
|
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
|
|
}
|
|
|
|
return { rows, columns: maxColumns };
|
|
}
|
|
|
|
function statusLabel(kind: FileStatusKind): string {
|
|
return kind;
|
|
}
|
|
|
|
function displayCommitFile(file: GitCommitFile): string {
|
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
|
}
|
|
|
|
function baseName(path: string): string {
|
|
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
|
}
|
|
|
|
function commitFileName(file: GitCommitFile): string {
|
|
return file.old_path
|
|
? `${baseName(file.old_path)} -> ${baseName(file.path)}`
|
|
: baseName(file.path);
|
|
}
|
|
|
|
function authorInitials(name: string): string {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "?";
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
|
}
|
|
|
|
function commitKind(commit: GitCommit): "merge" | "root" | "commit" {
|
|
if (commit.parents.length > 1) return "merge";
|
|
if (commit.parents.length === 0) return "root";
|
|
return "commit";
|
|
}
|
|
|
|
function commitKindLabel(commit: GitCommit): string {
|
|
const kind = commitKind(commit);
|
|
if (kind === "merge") return "merge";
|
|
if (kind === "root") return "root";
|
|
return "commit";
|
|
}
|
|
|
|
let localBranchNameSet = $derived(new Set(localBranchNames));
|
|
|
|
function uniqueStrings(values: string[]): string[] {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
|
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
|
|
|
function branchIsVisible(branch: string): boolean {
|
|
if (branch === activeUpstream && activeUpstream) {
|
|
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
|
}
|
|
return !hiddenGraphBranches.has(branch);
|
|
}
|
|
|
|
function visibleBranchLabels(labels: string[]): string[] {
|
|
return labels.filter(branchIsVisible);
|
|
}
|
|
|
|
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
|
const directBranches = graphBranchRefs(commit);
|
|
if (directBranches.length > 0) return directBranches;
|
|
|
|
const containingBranches = row?.branchLabels ?? [];
|
|
if (containingBranches.length <= 3) return containingBranches;
|
|
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
|
}
|
|
|
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
|
const directBranches = graphBranchRefs(commit);
|
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
|
|
|
const containingBranches = row?.branchLabels ?? [];
|
|
if (containingBranches.length === 0) return commit.short_hash;
|
|
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
|
}
|
|
|
|
function segmentIsVisible(segment: GraphSegment): boolean {
|
|
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
|
}
|
|
|
|
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
|
|
if (!activeBranch || !activeUpstream) return "";
|
|
const hasLocal = labels.includes(activeBranch);
|
|
const hasRemote = labels.includes(activeUpstream);
|
|
if (hasLocal && !hasRemote) return "ahead";
|
|
if (hasRemote && !hasLocal) return "behind";
|
|
return "";
|
|
}
|
|
|
|
function branchesAreVisible(branches: string[]): boolean {
|
|
if (graphBranchNames.length === 0) return true;
|
|
return branches.some(branchIsVisible);
|
|
}
|
|
|
|
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
|
return branchesAreVisible(row?.branchLabels ?? []);
|
|
}
|
|
|
|
function nearestVisibleGraphParents(
|
|
hash: string,
|
|
visibleHashes: Set<string>,
|
|
commitByHash: Map<string, GitCommit>,
|
|
seen: Set<string>,
|
|
): string[] {
|
|
if (visibleHashes.has(hash)) return [hash];
|
|
if (seen.has(hash)) return [];
|
|
seen.add(hash);
|
|
|
|
const commit = commitByHash.get(hash);
|
|
if (!commit) return [];
|
|
return uniqueStrings(
|
|
commit.parents.flatMap((parentHash) => (
|
|
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set(seen))
|
|
)),
|
|
);
|
|
}
|
|
|
|
function branchMembershipByHash(items: GitCommit[]): Map<string, string[]> {
|
|
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
|
|
const membership = new Map<string, Set<string>>();
|
|
|
|
for (const commit of items) {
|
|
for (const branch of graphBranchRefs(commit)) {
|
|
const stack = [commit.hash];
|
|
const seen = new Set<string>();
|
|
|
|
while (stack.length > 0) {
|
|
const hash = stack.pop();
|
|
if (!hash || seen.has(hash)) continue;
|
|
seen.add(hash);
|
|
|
|
let branches = membership.get(hash);
|
|
if (!branches) {
|
|
branches = new Set<string>();
|
|
membership.set(hash, branches);
|
|
}
|
|
branches.add(branch);
|
|
|
|
const parentCommit = commitByHash.get(hash);
|
|
if (parentCommit) stack.push(...parentCommit.parents);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Map(
|
|
items.map((commit) => [
|
|
commit.hash,
|
|
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function visibleCommitEntriesForGraph(items: GitCommit[], branchMembership: Map<string, string[]>): VisibleCommitEntry[] {
|
|
const visibleItems = items.filter((commit) => branchesAreVisible(branchMembership.get(commit.hash) ?? []));
|
|
const visibleHashes = new Set(visibleItems.map((commit) => commit.hash));
|
|
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
|
|
|
|
return visibleItems.map((commit) => {
|
|
const parents = uniqueStrings(
|
|
commit.parents.flatMap((parentHash) => (
|
|
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set())
|
|
)),
|
|
);
|
|
return { commit, graphCommit: { ...commit, parents } };
|
|
});
|
|
}
|
|
|
|
function toggleGraphBranch(branch: string) {
|
|
const next = new Set(hiddenGraphBranches);
|
|
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
|
hiddenGraphBranches = next;
|
|
userAdjustedBranchFilter = true;
|
|
}
|
|
|
|
function showAllGraphBranches() {
|
|
hiddenGraphBranches = new Set();
|
|
userAdjustedBranchFilter = true;
|
|
}
|
|
|
|
function hideAllGraphBranches() {
|
|
hiddenGraphBranches = new Set(localBranchNames);
|
|
userAdjustedBranchFilter = true;
|
|
}
|
|
|
|
function openBranchDialog() {
|
|
branchDialogOpen = true;
|
|
}
|
|
|
|
function closeBranchDialog() {
|
|
branchDialogOpen = false;
|
|
}
|
|
|
|
function handleBranchDialogKeydown(event: KeyboardEvent) {
|
|
if (branchDialogOpen && event.key === "Escape") {
|
|
closeBranchDialog();
|
|
}
|
|
}
|
|
|
|
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (isBusy) return;
|
|
|
|
if (contextCommit?.hash === commit.hash) {
|
|
closeCommitContextMenu();
|
|
return;
|
|
}
|
|
|
|
const panelRect = panelElement?.getBoundingClientRect();
|
|
const buttonRect = event.currentTarget instanceof HTMLElement
|
|
? event.currentTarget.getBoundingClientRect()
|
|
: null;
|
|
const rawX = panelRect && buttonRect ? buttonRect.right - panelRect.left - 184 : event.offsetX;
|
|
const rawY = panelRect && buttonRect ? buttonRect.bottom - panelRect.top + 4 : event.offsetY;
|
|
const maxX = Math.max(8, (panelRect?.width ?? window.innerWidth) - 192);
|
|
const maxY = Math.max(8, (panelRect?.height ?? window.innerHeight) - 96);
|
|
|
|
contextCommit = commit;
|
|
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
|
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
|
}
|
|
|
|
function closeCommitContextMenu() {
|
|
contextCommit = null;
|
|
}
|
|
|
|
async function createBranchFromContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onCreateBranchFromCommit(commit);
|
|
}
|
|
|
|
async function restoreContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onRestoreCommit(commit);
|
|
}
|
|
|
|
async function cherryPickContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onCherryPickCommit(commit);
|
|
}
|
|
|
|
async function revertContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onRevertCommit(commit);
|
|
}
|
|
|
|
function handleWindowKeydown(event: KeyboardEvent) {
|
|
if (event.key !== "Escape") return;
|
|
closeCommitContextMenu();
|
|
handleBranchDialogKeydown(event);
|
|
}
|
|
|
|
function localBranchRefs(commit: GitCommit): string[] {
|
|
const seen = new Set<string>();
|
|
const labels: string[] = [];
|
|
for (const ref of commit.refs) {
|
|
const label = refLabel(ref);
|
|
if (!localBranchNameSet.has(label) || seen.has(label)) continue;
|
|
seen.add(label);
|
|
labels.push(label);
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
function graphBranchRefs(commit: GitCommit): string[] {
|
|
const seen = new Set<string>();
|
|
const labels: string[] = [];
|
|
for (const ref of commit.refs) {
|
|
const label = refLabel(ref);
|
|
if (!graphBranchNameSet.has(label) || seen.has(label)) continue;
|
|
seen.add(label);
|
|
labels.push(label);
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
function visibleRefs(commit: GitCommit): string[] {
|
|
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
|
}
|
|
|
|
function refClass(ref: string): string {
|
|
if (ref.startsWith("HEAD")) return "head";
|
|
if (ref.startsWith("tag:")) return "tag";
|
|
if (localBranchNameSet.has(refLabel(ref))) return "branch";
|
|
if (ref.includes("/")) return "remote";
|
|
return "branch";
|
|
}
|
|
|
|
function refLabel(ref: string): string {
|
|
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
|
}
|
|
|
|
function formatCommitDate(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return commitDateFormatter.format(date);
|
|
}
|
|
|
|
$effect(() => {
|
|
const available = new Set(localBranchNames);
|
|
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
|
if (nextHidden.size !== hiddenGraphBranches.size) {
|
|
hiddenGraphBranches = nextHidden;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
|
? activeBranch
|
|
: (graphBranchNames[0] ?? "");
|
|
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
|
|
|
if (!defaultBranch) {
|
|
if (lastDefaultFilterKey !== defaultFilterKey) {
|
|
hiddenGraphBranches = new Set();
|
|
userAdjustedBranchFilter = false;
|
|
lastDefaultFilterKey = defaultFilterKey;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (lastDefaultFilterKey !== defaultFilterKey) {
|
|
userAdjustedBranchFilter = false;
|
|
lastDefaultFilterKey = defaultFilterKey;
|
|
}
|
|
|
|
if (!userAdjustedBranchFilter) {
|
|
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
|
}
|
|
});
|
|
|
|
let branchMembership = $derived(branchMembershipByHash(commits));
|
|
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
|
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
|
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
|
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
|
|
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
|
let graphRows = $derived(graph.rows);
|
|
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
|
</script>
|
|
|
|
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
|
|
|
|
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
|
<div class="section-head">
|
|
<div>
|
|
<span class="eyebrow">History</span>
|
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
|
</div>
|
|
{#if localBranchNames.length > 0}
|
|
<div class="section-head-actions">
|
|
<button
|
|
class="graph-branch-dialog-button"
|
|
type="button"
|
|
onclick={openBranchDialog}
|
|
title="Select branches shown in the graph"
|
|
>
|
|
<GitBranch size={13} aria-hidden="true" />
|
|
Branches
|
|
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !hasRepository}
|
|
<div class="blank-state">No repository loaded.</div>
|
|
{:else if commits.length === 0}
|
|
<div class="blank-state">No commits returned.</div>
|
|
{:else}
|
|
<div class="history-list graph-list overflow-auto">
|
|
{#if visibleCommits.length === 0}
|
|
<div class="blank-state">No loaded commits match the selected branches.</div>
|
|
{/if}
|
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
|
{@const item = entry.commit}
|
|
{@const row = graphRows[rowIndex]}
|
|
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
|
{@const otherRefs = visibleRefs(item)}
|
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
|
<article
|
|
class="commit-row graph-row"
|
|
class:graph-ahead-row={rowSyncClass === "ahead"}
|
|
class:graph-behind-row={rowSyncClass === "behind"}
|
|
class:merge-row={item.parents.length > 1}
|
|
class:root-row={item.parents.length === 0}
|
|
class:tip-row={item.refs.length > 0}
|
|
>
|
|
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
|
{#if row}
|
|
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
|
{#each row.top as seg}
|
|
<path
|
|
class:hidden-branch={!segmentIsVisible(seg)}
|
|
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
|
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
|
d={graphPath(seg, 0, 50)}
|
|
stroke={seg.color}
|
|
stroke-width="2.2"
|
|
vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/each}
|
|
{#each row.bottom as seg}
|
|
<path
|
|
class:hidden-branch={!segmentIsVisible(seg)}
|
|
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
|
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
|
d={graphPath(seg, 50, 100)}
|
|
stroke={seg.color}
|
|
stroke-width="2.2"
|
|
vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/each}
|
|
</svg>
|
|
<span
|
|
class="graph-dot"
|
|
class:merge={item.parents.length > 1}
|
|
class:tip={item.refs.length > 0}
|
|
class:hidden-branch={!rowGraphIsVisible(row)}
|
|
class:ahead={rowSyncClass === "ahead"}
|
|
class:behind={rowSyncClass === "behind"}
|
|
title={commitHoverTitle(item, row)}
|
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
|
></span>
|
|
{#if hoverBranchRefs.length > 0}
|
|
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
|
{#each hoverBranchRefs as branch}
|
|
<span
|
|
class:remote={branch === activeUpstream}
|
|
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
|
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
|
title={branch}
|
|
>
|
|
<GitBranch size={10} aria-hidden="true" />
|
|
{branch}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="commit-body">
|
|
<div class="commit-card-head">
|
|
<span class="commit-avatar">
|
|
{authorInitials(item.author_name)}
|
|
</span>
|
|
<div class="commit-card-main">
|
|
<div class="commit-title-row">
|
|
<strong class="commit-summary" title={item.summary}>{item.summary}</strong>
|
|
<span class={`commit-kind ${commitKind(item)}`}>
|
|
{#if item.parents.length > 1}
|
|
<GitMerge size={12} aria-hidden="true" />
|
|
{/if}
|
|
{commitKindLabel(item)}
|
|
</span>
|
|
</div>
|
|
<div class="commit-meta-line">
|
|
<span class="commit-hash">{item.short_hash}</span>
|
|
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if otherRefs.length > 0}
|
|
<div class="ref-list" aria-label="Commit refs">
|
|
{#each otherRefs as ref}
|
|
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if item.files.length > 0}
|
|
<div class="commit-files">
|
|
<button
|
|
class="commit-files-toggle"
|
|
type="button"
|
|
onclick={() => onToggleCommitFiles(item.hash)}
|
|
aria-expanded={expandedCommitHashes.has(item.hash)}
|
|
>
|
|
{#if expandedCommitHashes.has(item.hash)}
|
|
<ChevronDown size={14} aria-hidden="true" />
|
|
{:else}
|
|
<ChevronRight size={14} aria-hidden="true" />
|
|
{/if}
|
|
{item.files.length} {item.files.length === 1 ? "file" : "files"} changed
|
|
</button>
|
|
|
|
{#if expandedCommitHashes.has(item.hash)}
|
|
<div class="commit-file-list" aria-label="Changed files">
|
|
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
|
|
<button
|
|
class="commit-file-button"
|
|
type="button"
|
|
onclick={() => onPreviewCommitFile(item, file)}
|
|
disabled={isBusy}
|
|
title={`Show differences before restoring - ${displayCommitFile(file)}`}
|
|
>
|
|
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
|
<strong>{commitFileName(file)}</strong>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="commit-actions">
|
|
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
|
<div class="commit-action-buttons">
|
|
<button
|
|
class="commit-menu-button"
|
|
type="button"
|
|
onclick={(event) => openCommitActionMenu(event, item)}
|
|
disabled={isBusy}
|
|
title="Commit actions"
|
|
aria-label={`Actions for ${item.short_hash}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={contextCommit?.hash === item.hash}
|
|
>
|
|
<EllipsisVertical size={15} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
{#if hasMore || isLoadingMore || loadMoreError}
|
|
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
|
|
{#if isLoadingMore}
|
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
|
<span>Loading older commits…</span>
|
|
{:else if loadMoreError}
|
|
<span title={loadMoreError}>Older commits could not be loaded.</span>
|
|
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
|
|
{:else}
|
|
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
|
|
Load older commits
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if contextCommit}
|
|
<div
|
|
class="history-context-menu"
|
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
|
role="menu"
|
|
tabindex="-1"
|
|
aria-label={`Actions for ${contextCommit.short_hash}`}
|
|
>
|
|
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
|
|
<GitBranch size={14} aria-hidden="true" />
|
|
Branch
|
|
</button>
|
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
|
<RotateCcw size={14} aria-hidden="true" />
|
|
Restore
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onclick={cherryPickContextCommit}
|
|
disabled={isBusy}
|
|
title="Apply this commit's changes on top of the current branch"
|
|
>
|
|
<Cherry size={14} aria-hidden="true" />
|
|
Cherry-pick
|
|
</button>
|
|
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
|
|
<RotateCcw size={14} aria-hidden="true" /> Revert
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if branchDialogOpen}
|
|
<div class="branch-filter-backdrop" role="presentation">
|
|
<div
|
|
class="branch-filter-dialog"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Select visible branches"
|
|
>
|
|
<header class="branch-filter-dialog-head">
|
|
<div>
|
|
<span class="eyebrow">Git graph</span>
|
|
<h3>Visible branches</h3>
|
|
</div>
|
|
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="branch-filter-summary">
|
|
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
|
|
<div class="branch-filter-actions">
|
|
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
|
|
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="branch-filter-dialog-list">
|
|
{#each localBranchNames as branch}
|
|
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
|
<input
|
|
type="checkbox"
|
|
checked={branchIsVisible(branch)}
|
|
onchange={() => toggleGraphBranch(branch)}
|
|
/>
|
|
<GitBranch size={14} aria-hidden="true" />
|
|
<span>{branch}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|