This release adds upstream tracking for branches and a local-only indicator in the UI. The Git integration now exposes upstreams and supports publish flows. This enables publishing and remote-tracking configuration. - Introduces upstream tracking and local-only branch UI markers - Updates toolbar to reflect publish-local state and status indicators - Bumps version to 2026.8.3 and updates changelog
1156 lines
43 KiB
Svelte
1156 lines
43 KiB
Svelte
<script lang="ts">
|
|
import { Check, Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, 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;
|
|
}
|
|
|
|
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
|
|
type CommitRefKind = "local" | "remote" | "head";
|
|
|
|
interface CommitBranchDecoration {
|
|
label: string;
|
|
kind: CommitRefKind;
|
|
current: boolean;
|
|
trackedRemote: string;
|
|
localOnly: boolean;
|
|
representedBranches: string[];
|
|
}
|
|
|
|
interface CommitRefSummary {
|
|
branches: CommitBranchDecoration[];
|
|
tags: string[];
|
|
other: string[];
|
|
primaryBranch: CommitBranchDecoration | null;
|
|
primaryTag: string;
|
|
overflowCount: number;
|
|
}
|
|
|
|
const GRAPH_COLORS = [
|
|
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
|
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
|
];
|
|
const GRAPH_LANE = 18;
|
|
const GRAPH_REF_ARM = 16;
|
|
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
|
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
});
|
|
|
|
interface Props {
|
|
commits: GitCommit[];
|
|
localBranchNames: string[];
|
|
localBranchUpstreams: Record<string, string>;
|
|
remoteBranchNames: string[];
|
|
activeBranch: string;
|
|
activeUpstream: string;
|
|
activeAhead: number;
|
|
activeBehind: number;
|
|
repositoryKey: string;
|
|
hasRepository: boolean;
|
|
isBusy: boolean;
|
|
hasMore: boolean;
|
|
isLoadingMore: boolean;
|
|
loadMoreError: string;
|
|
expandedCommitHashes: Set<string>;
|
|
selectedCommitHash: 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;
|
|
onOpenCommitNote: (commit: GitCommit) => void;
|
|
onSelectCommit: (commit: GitCommit) => void;
|
|
}
|
|
|
|
let {
|
|
commits = [],
|
|
localBranchNames = [],
|
|
localBranchUpstreams = {},
|
|
remoteBranchNames = [],
|
|
activeBranch = "",
|
|
activeUpstream = "",
|
|
activeAhead = 0,
|
|
activeBehind = 0,
|
|
repositoryKey = "",
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
hasMore = false,
|
|
isLoadingMore = false,
|
|
loadMoreError = "",
|
|
expandedCommitHashes = new Set(),
|
|
selectedCommitHash = "",
|
|
onLoadMore = () => {},
|
|
onRestoreCommit = () => {},
|
|
onPreviewCommitFile = () => {},
|
|
onToggleCommitFiles = () => {},
|
|
onCreateBranchFromCommit = () => {},
|
|
onCherryPickCommit = () => {},
|
|
onRevertCommit = () => {},
|
|
onOpenCommitNote = () => {},
|
|
onSelectCommit = () => {},
|
|
}: Props = $props();
|
|
|
|
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
|
|
let customVisibleBranches = $state<Set<string>>(new Set());
|
|
let loadedVisibilityRepository = $state("");
|
|
let branchDialogOpen = $state(false);
|
|
let expandedRefsCommitHash = $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]);
|
|
const firstParent = commit.parents[0] ?? null;
|
|
after[col] = firstParent;
|
|
afterBranches[col] = firstParent
|
|
? (branchMembership.get(firstParent) ?? 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] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
|
|
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));
|
|
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
|
|
|
function uniqueStrings(values: string[]): string[] {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
|
|
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
|
|
|
function focusBranchNames(): string[] {
|
|
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
|
|
if (focused.length > 0) return focused;
|
|
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
|
|
}
|
|
|
|
function branchNamesForMode(): string[] {
|
|
if (branchVisibilityMode === "focus") return focusBranchNames();
|
|
if (branchVisibilityMode === "local") return localBranchNames;
|
|
if (branchVisibilityMode === "all") return graphBranchNames;
|
|
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
|
|
}
|
|
|
|
let visibleGraphBranchNames = $derived(branchNamesForMode());
|
|
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
|
|
|
|
function branchIsVisible(branch: string): boolean {
|
|
return visibleGraphBranchNameSet.has(branch);
|
|
}
|
|
|
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
|
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
|
|
|
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
|
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((branch) => visibleGraphBranchNameSet.has(branch));
|
|
}
|
|
|
|
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 = branchVisibilityMode === "custom"
|
|
? new Set(customVisibleBranches)
|
|
: new Set(visibleGraphBranchNames);
|
|
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
|
customVisibleBranches = next;
|
|
branchVisibilityMode = "custom";
|
|
}
|
|
|
|
function showAllGraphBranches() {
|
|
branchVisibilityMode = "all";
|
|
}
|
|
|
|
function hideAllGraphBranches() {
|
|
customVisibleBranches = new Set();
|
|
branchVisibilityMode = "custom";
|
|
}
|
|
|
|
function showFocusGraphBranches() {
|
|
branchVisibilityMode = "focus";
|
|
}
|
|
|
|
function openBranchDialog() {
|
|
branchDialogOpen = true;
|
|
}
|
|
|
|
function closeBranchDialog() {
|
|
branchDialogOpen = false;
|
|
}
|
|
|
|
function handleBranchDialogKeydown(event: KeyboardEvent) {
|
|
if (branchDialogOpen && event.key === "Escape") {
|
|
closeBranchDialog();
|
|
}
|
|
}
|
|
|
|
function toggleCommitRefs(commit: GitCommit) {
|
|
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function openContextCommitNote() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
onSelectCommit(commit);
|
|
await onOpenCommitNote(commit);
|
|
}
|
|
|
|
async function openCommitNote(commit: GitCommit) {
|
|
if (isBusy) return;
|
|
onSelectCommit(commit);
|
|
await onOpenCommitNote(commit);
|
|
}
|
|
|
|
function handleWindowKeydown(event: KeyboardEvent) {
|
|
if (event.key !== "Escape") return;
|
|
closeCommitContextMenu();
|
|
expandedRefsCommitHash = "";
|
|
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 refLabel(ref: string): string {
|
|
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
|
}
|
|
|
|
function isSymbolicRemoteHead(ref: string): boolean {
|
|
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
|
|
const label = refLabel(ref);
|
|
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
|
|
}
|
|
|
|
function remoteName(branch: string): string {
|
|
return branch.split("/", 1)[0] ?? branch;
|
|
}
|
|
|
|
function configuredUpstreamForBranch(local: string): string {
|
|
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
|
|
}
|
|
|
|
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
|
|
const configuredUpstream = configuredUpstreamForBranch(local);
|
|
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
|
|
}
|
|
|
|
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
|
|
const priority = (item: CommitBranchDecoration) => {
|
|
if (item.label === activeBranch) return 0;
|
|
if (item.kind === "head") return 1;
|
|
if (item.kind === "local") return 2;
|
|
if (item.label === activeUpstream) return 3;
|
|
return 4;
|
|
};
|
|
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
|
|
}
|
|
|
|
function commitRefSummary(commit: GitCommit): CommitRefSummary {
|
|
const local = new Set<string>();
|
|
const remote = new Set<string>();
|
|
const tags = new Set<string>();
|
|
const other = new Set<string>();
|
|
let detachedHead = false;
|
|
|
|
for (const ref of commit.refs) {
|
|
if (isSymbolicRemoteHead(ref)) continue;
|
|
const label = refLabel(ref);
|
|
if (!label) continue;
|
|
if (ref.startsWith("tag:")) {
|
|
tags.add(label);
|
|
} else if (localBranchNameSet.has(label)) {
|
|
local.add(label);
|
|
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
|
|
remote.add(label);
|
|
} else if (label === "HEAD") {
|
|
detachedHead = true;
|
|
} else {
|
|
other.add(label);
|
|
}
|
|
}
|
|
|
|
const remainingRemote = [...remote];
|
|
const branches: CommitBranchDecoration[] = [...local].map((label) => {
|
|
const configuredUpstream = configuredUpstreamForBranch(label);
|
|
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
|
|
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
|
|
return {
|
|
label,
|
|
kind: "local",
|
|
current: label === activeBranch,
|
|
trackedRemote,
|
|
localOnly: !configuredUpstream,
|
|
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
|
|
};
|
|
});
|
|
|
|
if (detachedHead) {
|
|
branches.push({
|
|
label: "HEAD",
|
|
kind: "head",
|
|
current: true,
|
|
trackedRemote: "",
|
|
localOnly: false,
|
|
representedBranches: [],
|
|
});
|
|
}
|
|
|
|
branches.push(...remainingRemote.map((label) => ({
|
|
label,
|
|
kind: "remote" as const,
|
|
current: false,
|
|
trackedRemote: "",
|
|
localOnly: false,
|
|
representedBranches: [label],
|
|
})));
|
|
|
|
const sortedBranches = branches.sort(compareBranchDecorations);
|
|
const primaryBranch = sortedBranches.find(
|
|
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
|
|
) ?? null;
|
|
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
|
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
|
|
const primaryTag = sortedTags[0] ?? "";
|
|
|
|
return {
|
|
branches: sortedBranches,
|
|
tags: sortedTags,
|
|
other: sortedOther,
|
|
primaryBranch,
|
|
primaryTag,
|
|
overflowCount:
|
|
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
|
|
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
|
|
+ sortedOther.length,
|
|
};
|
|
}
|
|
|
|
function branchStatusLabel(branch: CommitBranchDecoration): string {
|
|
if (branch.trackedRemote) return `✓ ${remoteName(branch.trackedRemote)}`;
|
|
if (branch.label === activeBranch) {
|
|
const parts = [];
|
|
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
|
|
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
|
|
return parts.join(" ");
|
|
}
|
|
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
|
|
return "";
|
|
}
|
|
|
|
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
|
if (branch.localOnly) return `${branch.label} · Local only — not published yet`;
|
|
if (branch.trackedRemote) return `${branch.label} · up to date with ${branch.trackedRemote}`;
|
|
const status = branchStatusLabel(branch);
|
|
if (status) return `${branch.label} · ${status}`;
|
|
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
|
}
|
|
|
|
function formatCommitDate(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return commitDateFormatter.format(date);
|
|
}
|
|
|
|
$effect(() => {
|
|
const currentRepository = repositoryKey;
|
|
if (loadedVisibilityRepository === currentRepository) return;
|
|
loadedVisibilityRepository = currentRepository;
|
|
expandedRefsCommitHash = "";
|
|
|
|
if (!currentRepository) {
|
|
branchVisibilityMode = "focus";
|
|
customVisibleBranches = new Set();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
|
|
if (!stored) {
|
|
branchVisibilityMode = "focus";
|
|
customVisibleBranches = new Set();
|
|
return;
|
|
}
|
|
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
|
|
const mode = parsed.mode;
|
|
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
|
|
? mode
|
|
: "focus";
|
|
customVisibleBranches = new Set(
|
|
Array.isArray(parsed.branches)
|
|
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
|
|
: [],
|
|
);
|
|
} catch {
|
|
branchVisibilityMode = "focus";
|
|
customVisibleBranches = new Set();
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const currentRepository = repositoryKey;
|
|
const mode = branchVisibilityMode;
|
|
const branches = [...customVisibleBranches];
|
|
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
|
|
try {
|
|
localStorage.setItem(
|
|
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
|
|
JSON.stringify({ mode, branches }),
|
|
);
|
|
} catch {
|
|
// The graph still works if storage is unavailable.
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
|
const available = new Set(graphBranchNames);
|
|
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
|
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!selectedCommitHash) return;
|
|
queueMicrotask(() => {
|
|
panelElement
|
|
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
|
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
});
|
|
});
|
|
|
|
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(visibleGraphBranchNames.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 graphBranchNames.length > 0}
|
|
<div class="section-head-actions">
|
|
<button
|
|
class="graph-branch-dialog-button"
|
|
type="button"
|
|
onclick={openBranchDialog}
|
|
title="Customize visible branches"
|
|
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
|
>
|
|
<GitBranch size={13} aria-hidden="true" />
|
|
Branches
|
|
<span>{visibleBranchCount}/{graphBranchNames.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 refSummary = commitRefSummary(item)}
|
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
|
<article
|
|
class="commit-row graph-row"
|
|
class:selected={selectedCommitHash === item.hash}
|
|
data-commit-hash={item.hash}
|
|
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}
|
|
{#if refSummary.primaryBranch}
|
|
<path
|
|
class="graph-ref-connector"
|
|
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
|
|
stroke={row.dotColor}
|
|
stroke-width="1.5"
|
|
vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/if}
|
|
</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}
|
|
</div>
|
|
|
|
<div
|
|
class="commit-body"
|
|
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
|
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
|
>
|
|
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
|
<div class="commit-ref-area">
|
|
<div class="commit-ref-strip" aria-label="Commit references">
|
|
{#if refSummary.primaryBranch}
|
|
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
|
<span
|
|
class="compact-ref-chip branch"
|
|
class:current={refSummary.primaryBranch.current}
|
|
class:remote={refSummary.primaryBranch.kind === "remote"}
|
|
title={branchDecorationTitle(refSummary.primaryBranch)}
|
|
>
|
|
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
|
|
<span>{refSummary.primaryBranch.label}</span>
|
|
{#if branchStatusLabel(refSummary.primaryBranch)}
|
|
<small class:up-to-date={Boolean(refSummary.primaryBranch.trackedRemote)}>
|
|
{#if refSummary.primaryBranch.trackedRemote}<Check size={9} aria-hidden="true" />{/if}
|
|
{branchStatusLabel(refSummary.primaryBranch).replace(/^✓\s*/, "")}
|
|
</small>
|
|
{/if}
|
|
</span>
|
|
{#if refSummary.primaryBranch.localOnly}
|
|
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
|
<CloudOff size={9} aria-hidden="true" />LOCAL
|
|
</span>
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
{#if refSummary.primaryTag}
|
|
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
|
<Tag size={10} aria-hidden="true" />
|
|
<span>{refSummary.primaryTag}</span>
|
|
</span>
|
|
{/if}
|
|
{#if refSummary.overflowCount > 0}
|
|
<button
|
|
class="compact-ref-overflow"
|
|
type="button"
|
|
onclick={() => toggleCommitRefs(item)}
|
|
aria-expanded={expandedRefsCommitHash === item.hash}
|
|
aria-controls={`commit-refs-${item.hash}`}
|
|
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
|
>
|
|
+{refSummary.overflowCount}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
|
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
|
<strong>References on this commit</strong>
|
|
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
|
<section>
|
|
<span>Local</span>
|
|
<div>
|
|
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
|
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
|
<i aria-hidden="true"></i>{branch.label}
|
|
{#if branch.current}<small>Current</small>{/if}
|
|
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
|
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
|
<section>
|
|
<span>Remote</span>
|
|
<div>
|
|
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
|
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{#if refSummary.tags.length > 0}
|
|
<section>
|
|
<span>Tags</span>
|
|
<div>
|
|
{#each refSummary.tags as tag}
|
|
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{#if refSummary.other.length > 0}
|
|
<section>
|
|
<span>Other</span>
|
|
<div>
|
|
{#each refSummary.other as ref}
|
|
<span class="commit-ref-detail-item">{ref}</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<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 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 commit-note-button"
|
|
type="button"
|
|
onclick={() => openCommitNote(item)}
|
|
disabled={isBusy}
|
|
title={`Open internal note for ${item.short_hash}`}
|
|
aria-label={`Open internal note for ${item.short_hash}`}
|
|
>
|
|
<StickyNote size={14} aria-hidden="true" />
|
|
</button>
|
|
<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={openContextCommitNote} disabled={isBusy}>
|
|
<StickyNote size={14} aria-hidden="true" />
|
|
Note
|
|
</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 {graphBranchNames.length} branches selected</span>
|
|
<div class="branch-filter-actions">
|
|
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
|
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
|
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="branch-filter-dialog-list">
|
|
{#if localBranchNames.length > 0}
|
|
<span class="branch-filter-group-label">Local</span>
|
|
{#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}
|
|
{/if}
|
|
{#if remoteBranchNames.length > 0}
|
|
<span class="branch-filter-group-label">Remote</span>
|
|
{#each remoteBranchNames as branch}
|
|
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
|
|
<input
|
|
type="checkbox"
|
|
checked={branchIsVisible(branch)}
|
|
onchange={() => toggleGraphBranch(branch)}
|
|
/>
|
|
<GitBranch size={14} aria-hidden="true" />
|
|
<span>{branch}</span>
|
|
</label>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|