feat(history-ui): add resizable commit history aside

The commit history panel is now resizable with a draggable handle and
keyboard support. The chosen width is persisted in local storage so the
layout stays consistent across sessions. Git commit loading was also
adjusted to include all branch tips for a more complete graph.

- Add width persistence and pointer/keyboard resizing for history panel
- Update commit graph query to use topo-order across all refs
- Extend tests to ensure branch tips are included in commit results
This commit is contained in:
Christoph Brandau
2026-07-03 23:35:46 +02:00
parent 835bfae254
commit 9c371ec520
4 changed files with 821 additions and 100 deletions
+327 -20
View File
@@ -1,28 +1,38 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw } from "@lucide/svelte";
import { ChevronDown, ChevronRight, 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 = [
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0",
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f",
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 16;
const GRAPH_LANE = 18;
interface Props {
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
expandedCommitHashes: Set<string>;
@@ -34,6 +44,9 @@
let {
commits = [],
localBranchNames = [],
activeBranch = "",
repositoryKey = "",
hasRepository = false,
isBusy = false,
expandedCommitHashes = new Set(),
@@ -43,6 +56,11 @@
onCreateBranchFromCommit = () => {},
}: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false);
let lastDefaultFilterKey = $state("");
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
@@ -59,13 +77,15 @@
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
}
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
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) {
@@ -75,18 +95,27 @@
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;
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); }
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
after[slot] = commit.parents[p];
afterBranches[slot] = [];
fromCommit.add(slot);
}
@@ -94,19 +123,28 @@
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) });
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) });
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), top, bottom });
rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
lanes = after.slice();
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.pop();
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);
}
@@ -151,9 +189,159 @@
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 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 handleBranchDialogBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
closeBranchDialog();
}
}
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";
}
@@ -168,45 +356,108 @@
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
let graph = $derived(computeGraph(commits));
$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(graph.columns, 1) * GRAPH_LANE);
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
</script>
<svelte:window onkeydown={handleBranchDialogKeydown} />
<section class="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>
<span class="pill pill-count">{commits.length}</span>
<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 commits as item, rowIndex (item.hash)}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0}>
{@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
{@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"
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"
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/>
{/each}
@@ -214,8 +465,21 @@
<span
class="graph-dot"
class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
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>
@@ -241,9 +505,9 @@
</div>
</div>
{#if item.refs.length > 0}
{#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref}
{#each otherRefs as ref}
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each}
</div>
@@ -303,3 +567,46 @@
</div>
{/if}
</section>
{#if branchDialogOpen}
<div class="branch-filter-backdrop" role="presentation" onclick={handleBranchDialogBackdropClick}>
<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}