Repo status updates for the management view are now refreshed in the background after switching, without blocking the initial open flow. The history graph and hover labels now distinguish local vs upstream branches, visually marking commits that are ahead or behind. - Add background fetch helper and shared status update logic - Pass active upstream into HistoryPanel and render sync styling
767 lines
27 KiB
Svelte
767 lines
27 KiB
Svelte
<script lang="ts">
|
|
import { Cherry, 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;
|
|
activeUpstream: 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;
|
|
onCherryPickCommit: (commit: GitCommit) => void;
|
|
}
|
|
|
|
let {
|
|
commits = [],
|
|
localBranchNames = [],
|
|
activeBranch = "",
|
|
activeUpstream = "",
|
|
repositoryKey = "",
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
expandedCommitHashes = new Set(),
|
|
onRestoreCommit = () => {},
|
|
onPreviewCommitFile = () => {},
|
|
onToggleCommitFiles = () => {},
|
|
onCreateBranchFromCommit = () => {},
|
|
onCherryPickCommit = () => {},
|
|
}: 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))];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 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
|
|
: (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>
|
|
<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)}
|
|
{@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}
|
|
</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>
|
|
</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}
|