Files
GitLite/src/lib/components/HistoryPanel.svelte
T
Christoph Brandau eac0f059fa feat(history): improve commit hover branch labels and titles
The history panel now generates more informative hover text for commits.
It prefers direct local branch refs when available, otherwise it derives
a concise list from containing branch labels and summarizes overflow.

- Add helpers to compute hover branch labels and title text
- Update the graph row hover rendering to use the new helpers
2026-07-06 11:31:41 +02:00

705 lines
25 KiB
Svelte

<script lang="ts">
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, 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;
interface Props {
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
expandedCommitHashes: Set<string>;
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
}
let {
commits = [],
localBranchNames = [],
activeBranch = "",
repositoryKey = "",
hasRepository = false,
isBusy = false,
expandedCommitHashes = new Set(),
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
}: 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 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))];
}
function branchIsVisible(branch: string): boolean {
return !hiddenGraphBranches.has(branch);
}
function visibleBranchLabels(labels: string[]): string[] {
return labels.filter(branchIsVisible);
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = localBranchRefs(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 = localBranchRefs(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 branchesAreVisible(branches: string[]): boolean {
if (localBranchNames.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 localBranchRefs(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,
localBranchNames.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);
}
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 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 new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).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
: (localBranchNames[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>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
<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>
{/if}
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
{visibleCommits.length}
</span>
</div>
</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 if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#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)}
<article
class="commit-row graph-row"
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)}
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)}
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)}
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 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}
</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>
</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}